@vzn/vx-dte 0.0.651 → 0.0.652
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 +33 -2
- package/package.json +3 -3
- package/src/dispatch.ts +501 -26
- package/src/forecast.ts +1 -2
- package/src/messages.ts +14 -4
- package/src/orchestrator.ts +415 -202
- package/src/planner.ts +1322 -172
- package/src/plugin.ts +33 -3
- package/src/wire.ts +36 -8
- package/src/worker.ts +26 -4
package/src/planner.ts
CHANGED
|
@@ -50,8 +50,32 @@ export interface PlanTask {
|
|
|
50
50
|
readonly deps: readonly string[]
|
|
51
51
|
/** The platform core placed it on (`TaskNode.platform`): only a worker of it runs it. */
|
|
52
52
|
readonly platform?: string | undefined
|
|
53
|
+
/** Its number in the run's graph and its deps' (`planTasks`): a plan's shape reads them, not ids. */
|
|
54
|
+
readonly n?: number
|
|
55
|
+
readonly depsN?: Int32Array
|
|
53
56
|
}
|
|
54
57
|
|
|
58
|
+
/** The run's tasks numbered once, and each one's deps by number. */
|
|
59
|
+
interface Numbered {
|
|
60
|
+
readonly of: Map<string, Node>
|
|
61
|
+
readonly ids: readonly string[]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A task of the run's graph: its number, whether it runs, the tasks that run it waits on. */
|
|
65
|
+
interface Node {
|
|
66
|
+
readonly n: number
|
|
67
|
+
readonly node: TaskNode
|
|
68
|
+
readonly runs: boolean
|
|
69
|
+
deps?: string[]
|
|
70
|
+
depsN?: Int32Array
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The numbering each `depsN` is of. */
|
|
74
|
+
const numberedOf = new WeakMap<Int32Array, Numbered>()
|
|
75
|
+
|
|
76
|
+
/** A number for each of `nodes`, once a run. */
|
|
77
|
+
const numbers = new WeakMap<ReadonlyMap<string, TaskNode>, Numbered>()
|
|
78
|
+
|
|
55
79
|
/** A worker the plan can place on: from `at` ms on, with what it runs until each ends. */
|
|
56
80
|
export interface PlanWorker {
|
|
57
81
|
readonly id: string
|
|
@@ -63,6 +87,7 @@ export interface PlanWorker {
|
|
|
63
87
|
readonly id?: string
|
|
64
88
|
readonly cores: number
|
|
65
89
|
readonly memory: number
|
|
90
|
+
/** Below 0: past its time by as much. */
|
|
66
91
|
readonly endsIn: number
|
|
67
92
|
}[]
|
|
68
93
|
/** Holding no task: the run may let it go. */
|
|
@@ -96,6 +121,26 @@ export interface Plan {
|
|
|
96
121
|
readonly againInMs: number | undefined
|
|
97
122
|
}
|
|
98
123
|
|
|
124
|
+
/**
|
|
125
|
+
* What a plan foresees past its decisions, so the run follows it without
|
|
126
|
+
* playing it again: when each worker's last task in it ends (one idle
|
|
127
|
+
* before that is between tasks), when each start it wants later is due,
|
|
128
|
+
* and when each started now ends its work.
|
|
129
|
+
*/
|
|
130
|
+
interface Ahead {
|
|
131
|
+
/** When the window it played moves on, from now: plan again then. */
|
|
132
|
+
readonly untilInMs: number | undefined
|
|
133
|
+
readonly freeInMs: ReadonlyMap<string, number>
|
|
134
|
+
readonly later: readonly {
|
|
135
|
+
readonly size: Size
|
|
136
|
+
readonly inMs: number
|
|
137
|
+
readonly freeInMs: number
|
|
138
|
+
readonly joinInMs: number
|
|
139
|
+
}[]
|
|
140
|
+
/** For each started now, when its work ends and when it joins. */
|
|
141
|
+
readonly started: readonly { readonly freeInMs: number; readonly joinInMs: number }[]
|
|
142
|
+
}
|
|
143
|
+
|
|
99
144
|
/** History the run's finished tasks must have had before `slowdown` says anything. */
|
|
100
145
|
const SLOWDOWN_MS = 20_000
|
|
101
146
|
|
|
@@ -159,28 +204,55 @@ export function planTasks(
|
|
|
159
204
|
durationMs: (id: string) => number | undefined,
|
|
160
205
|
home?: string,
|
|
161
206
|
): PlanTask[] {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
207
|
+
// The graph is the run's, fixed: each task is numbered and its deps
|
|
208
|
+
// walked once a run, not at each plan (2026-11-05: a 5000-task sim
|
|
209
|
+
// spent a quarter here), and read by one lookup (2026-11-06).
|
|
210
|
+
let num = numbers.get(nodes)
|
|
211
|
+
if (num === undefined) {
|
|
212
|
+
const of = new Map<string, Node>()
|
|
213
|
+
const all: string[] = []
|
|
214
|
+
for (const [id, node] of nodes) {
|
|
215
|
+
of.set(id, { n: all.length, node, runs: node.config.exec?.command !== undefined })
|
|
216
|
+
all.push(id)
|
|
217
|
+
}
|
|
218
|
+
numbers.set(nodes, (num = { of, ids: all }))
|
|
171
219
|
}
|
|
172
|
-
|
|
220
|
+
const { of } = num
|
|
221
|
+
const depsOf = (e: Node): string[] => {
|
|
222
|
+
if (e.deps !== undefined) return e.deps
|
|
223
|
+
e.deps = []
|
|
224
|
+
const out: string[] = []
|
|
225
|
+
for (const d of e.node.deps) {
|
|
226
|
+
const x = of.get(d)
|
|
227
|
+
if (x === undefined) continue
|
|
228
|
+
if (x.runs) out.push(d)
|
|
229
|
+
else for (const dd of depsOf(x)) out.push(dd)
|
|
230
|
+
}
|
|
231
|
+
return (e.deps = out)
|
|
232
|
+
}
|
|
233
|
+
const out: PlanTask[] = []
|
|
234
|
+
for (const id of ids) {
|
|
235
|
+
const e = of.get(id)
|
|
236
|
+
if (e === undefined || !e.runs) continue
|
|
173
237
|
const n = need(id)
|
|
174
|
-
|
|
238
|
+
const deps = depsOf(e)
|
|
239
|
+
if (e.depsN === undefined) {
|
|
240
|
+
e.depsN = Int32Array.from(deps, (d) => of.get(d)!.n)
|
|
241
|
+
numberedOf.set(e.depsN, num)
|
|
242
|
+
}
|
|
243
|
+
out.push({
|
|
175
244
|
id,
|
|
176
245
|
cores: n.ask?.cores ?? n.cores,
|
|
177
246
|
memory: n.ask?.memory ?? n.memory,
|
|
178
247
|
above: n.above,
|
|
179
248
|
ms: durationMs(id),
|
|
180
|
-
deps
|
|
181
|
-
platform:
|
|
182
|
-
|
|
183
|
-
|
|
249
|
+
deps,
|
|
250
|
+
platform: e.node.platform ?? home,
|
|
251
|
+
n: e.n,
|
|
252
|
+
depsN: e.depsN,
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
return out
|
|
184
256
|
}
|
|
185
257
|
|
|
186
258
|
/**
|
|
@@ -209,15 +281,14 @@ export function missedWork(
|
|
|
209
281
|
if (known !== undefined) return known
|
|
210
282
|
changed.set(id, false)
|
|
211
283
|
const n = nodes.get(id)
|
|
212
|
-
const c = (executed.has(id) && n?.config.cache
|
|
284
|
+
const c = (executed.has(id) && n?.config.cache === true) || (n?.deps ?? []).some(changes)
|
|
213
285
|
changed.set(id, c)
|
|
214
286
|
return c
|
|
215
287
|
}
|
|
216
288
|
const missed = (id: string): boolean => {
|
|
217
289
|
const n = nodes.get(id)
|
|
218
290
|
return (
|
|
219
|
-
executed.has(id) ||
|
|
220
|
-
(n !== undefined && (n.config.cache === undefined || n.deps.some(changes)))
|
|
291
|
+
executed.has(id) || (n !== undefined && (n.config.cache !== true || n.deps.some(changes)))
|
|
221
292
|
)
|
|
222
293
|
}
|
|
223
294
|
return planTasks([...remaining].filter(missed), nodes, need, durationMs, home)
|
|
@@ -244,6 +315,10 @@ interface Slot extends Bin {
|
|
|
244
315
|
readonly at: number
|
|
245
316
|
/** When it is first given a task. */
|
|
246
317
|
first?: number
|
|
318
|
+
/** When its last task ends. */
|
|
319
|
+
free?: number
|
|
320
|
+
/** When its last task given in the play ends. */
|
|
321
|
+
last?: number
|
|
247
322
|
}
|
|
248
323
|
|
|
249
324
|
/**
|
|
@@ -264,36 +339,27 @@ function play(
|
|
|
264
339
|
tasks: readonly PlanTask[],
|
|
265
340
|
workers: readonly PlanWorker[],
|
|
266
341
|
how: Strategy,
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
342
|
+
// Played no further: past it the rest ends no sooner than its longest
|
|
343
|
+
// chain from there (2026-11-05: "We can plan as we go till next level";
|
|
344
|
+
// "it can look ahead 2 warmups").
|
|
345
|
+
horizon = Infinity,
|
|
346
|
+
): {
|
|
347
|
+
end: number
|
|
348
|
+
/** It stopped at `horizon` with work left. */
|
|
349
|
+
cut?: boolean
|
|
350
|
+
first: Map<string, number>
|
|
351
|
+
ends: Map<string | undefined, number>
|
|
352
|
+
free: () => Map<string, number>
|
|
353
|
+
} {
|
|
271
354
|
// A dependency running now ends when it does; one ended or a hit, now.
|
|
272
355
|
const runningIds = new Set<string>()
|
|
273
356
|
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
|
-
}
|
|
357
|
+
const { byId, after, afterAt, indexOf, waitedAt, tailOf, tailAt, kinds } = shapeOf(
|
|
358
|
+
tasks,
|
|
359
|
+
runningIds,
|
|
360
|
+
)
|
|
361
|
+
// By place, not id: maps keyed by id were a 5000-task play's most time (2026-11-05).
|
|
362
|
+
const waits = waitedAt.slice()
|
|
297
363
|
const slots: Slot[] = workers.map((w) => ({
|
|
298
364
|
id: w.id,
|
|
299
365
|
cores: w.cores,
|
|
@@ -304,21 +370,22 @@ function play(
|
|
|
304
370
|
running: 0,
|
|
305
371
|
}))
|
|
306
372
|
// Ends to come: [at, slot, cores, memory, task or undefined].
|
|
307
|
-
const ends
|
|
373
|
+
const ends = new Ends()
|
|
308
374
|
workers.forEach((w, i) => {
|
|
309
375
|
const s = slots[i]!
|
|
310
376
|
for (const r of w.running) {
|
|
311
377
|
s.used.cores += r.cores
|
|
312
378
|
s.used.memory += r.memory
|
|
313
379
|
s.running++
|
|
314
|
-
ends.push(
|
|
380
|
+
ends.push(Math.max(0, r.endsIn), s, r.cores, r.memory, -1, r.id)
|
|
381
|
+
s.free = Math.max(s.free ?? 0, r.endsIn)
|
|
315
382
|
}
|
|
316
383
|
})
|
|
317
384
|
// Ready tasks, ordered again only once one joins (`graphene`: or
|
|
318
385
|
// leaves): each step sorted every ready task, and a run of 9000 spent
|
|
319
386
|
// its orchestrator planning (2026-10-25).
|
|
320
|
-
const ready = new Ready(tasks)
|
|
321
|
-
let joining = tasks.filter((
|
|
387
|
+
const ready = new Ready(tasks, kinds)
|
|
388
|
+
let joining = tasks.filter((_, i) => waits[i] === 0)
|
|
322
389
|
let readyCount = joining.length
|
|
323
390
|
const spent = new Set<string>()
|
|
324
391
|
const bounds: Bounds = { done: false, open: true, free: 0, cores: 0, memory: 0 }
|
|
@@ -345,9 +412,33 @@ function play(
|
|
|
345
412
|
const widest = Math.max(1, ...workers.map((w) => w.cores))
|
|
346
413
|
let left = tasks.length
|
|
347
414
|
let now = 0
|
|
348
|
-
let last = Math.max(0,
|
|
415
|
+
let last = Math.max(0, ends.last())
|
|
416
|
+
// Core-ms of the tasks not yet given.
|
|
417
|
+
const workLeft = new Map<string | undefined, number>()
|
|
418
|
+
const spend = (t: PlanTask, by: number): void => {
|
|
419
|
+
const w = by * t.cores * (t.ms ?? UNKNOWN_MS)
|
|
420
|
+
workLeft.set(undefined, (workLeft.get(undefined) ?? 0) + w)
|
|
421
|
+
if (t.platform !== undefined) workLeft.set(t.platform, (workLeft.get(t.platform) ?? 0) + w)
|
|
422
|
+
}
|
|
423
|
+
if (horizon < Infinity) for (const t of tasks) spend(t, 1)
|
|
349
424
|
// When each platform's last task ends.
|
|
350
|
-
const byPlatform =
|
|
425
|
+
const byPlatform = (): Map<string | undefined, number> => {
|
|
426
|
+
const out = new Map<string | undefined, number>()
|
|
427
|
+
for (const s of slots)
|
|
428
|
+
if (s.last !== undefined) out.set(s.platform, Math.max(out.get(s.platform) ?? 0, s.last))
|
|
429
|
+
return out
|
|
430
|
+
}
|
|
431
|
+
// The joined slots, read again once the next one joins.
|
|
432
|
+
let joined: Slot[] = []
|
|
433
|
+
let nextJoin = -Infinity
|
|
434
|
+
// A task that filled a size waits for a bigger one still to join.
|
|
435
|
+
const later = (above: { cores: number; memory: number }, platform?: string): boolean =>
|
|
436
|
+
slots.some(
|
|
437
|
+
(s) =>
|
|
438
|
+
s.at > now &&
|
|
439
|
+
(platform === undefined || s.platform === platform) &&
|
|
440
|
+
(s.cores > above.cores || s.memory > above.memory),
|
|
441
|
+
)
|
|
351
442
|
for (;;) {
|
|
352
443
|
if (joining.length > 0 || ready.stale) {
|
|
353
444
|
// By the longest chain, those still ready keep their order and the
|
|
@@ -365,21 +456,21 @@ function play(
|
|
|
365
456
|
else ready.reset(order)
|
|
366
457
|
joining = []
|
|
367
458
|
}
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
slots.
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
459
|
+
if (now >= nextJoin) {
|
|
460
|
+
joined = slots.filter((s) => s.at <= now)
|
|
461
|
+
nextJoin = Infinity
|
|
462
|
+
for (const s of slots) if (s.at > now) nextJoin = Math.min(nextJoin, s.at)
|
|
463
|
+
}
|
|
464
|
+
// With none ready nothing is given: a third of a sim's steps (2026-11-05).
|
|
465
|
+
const to =
|
|
466
|
+
readyCount === 0
|
|
467
|
+
? NONE
|
|
468
|
+
: assign(ready.asks(spent, bounds, needs.size), joined, later, how.place, {
|
|
469
|
+
floor: (floor ??= floorOf(needs.values())),
|
|
470
|
+
kinds: needs.size,
|
|
471
|
+
spent,
|
|
472
|
+
bounds,
|
|
473
|
+
})
|
|
383
474
|
// `graphene` ranks against the longest chain still ready.
|
|
384
475
|
if (to.size > 0 && how.order === 'graphene') ready.stale = true
|
|
385
476
|
for (const [id, best] of to) {
|
|
@@ -388,46 +479,327 @@ function play(
|
|
|
388
479
|
readyCount--
|
|
389
480
|
count(t, -1)
|
|
390
481
|
best.first ??= now
|
|
482
|
+
best.free = Math.max(best.free ?? 0, now + (t.ms ?? UNKNOWN_MS))
|
|
391
483
|
const end = now + (t.ms ?? UNKNOWN_MS)
|
|
392
|
-
ends.push(
|
|
484
|
+
ends.push(
|
|
485
|
+
end,
|
|
486
|
+
best,
|
|
487
|
+
Math.min(t.cores, best.cores),
|
|
488
|
+
Math.min(t.memory, best.memory),
|
|
489
|
+
indexOf.get(id)!,
|
|
490
|
+
undefined,
|
|
491
|
+
)
|
|
393
492
|
last = Math.max(last, end)
|
|
394
|
-
|
|
493
|
+
best.last = Math.max(best.last ?? 0, end)
|
|
494
|
+
if (horizon < Infinity) spend(t, -1)
|
|
395
495
|
}
|
|
396
496
|
const first = (): Map<string, number> =>
|
|
397
497
|
new Map(slots.flatMap((s) => (s.first === undefined ? [] : [[s.id, s.first] as const])))
|
|
398
|
-
|
|
498
|
+
const free = (): Map<string, number> =>
|
|
499
|
+
new Map(slots.flatMap((s) => (s.free === undefined ? [] : [[s.id, s.free] as const])))
|
|
500
|
+
if (left === 0 && readyCount === 0)
|
|
501
|
+
return { end: last, first: first(), ends: byPlatform(), free }
|
|
399
502
|
// 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])
|
|
503
|
+
let next = ends.next()
|
|
402
504
|
for (const s of slots) if (s.at > now) next = Math.min(next, s.at)
|
|
505
|
+
if (next >= horizon && next < Infinity) {
|
|
506
|
+
// No sooner than its longest chain, nor than its work spread on
|
|
507
|
+
// every core it has: the workers it asks later are the next plan's.
|
|
508
|
+
// Of each platform on its own workers, and all of it on all.
|
|
509
|
+
const work = new Map(workLeft)
|
|
510
|
+
const cores = new Map<string | undefined, number>()
|
|
511
|
+
for (const s of slots) {
|
|
512
|
+
cores.set(undefined, (cores.get(undefined) ?? 0) + s.cores)
|
|
513
|
+
if (s.platform !== undefined) cores.set(s.platform, (cores.get(s.platform) ?? 0) + s.cores)
|
|
514
|
+
}
|
|
515
|
+
for (const e of ends.all())
|
|
516
|
+
work.set(undefined, (work.get(undefined) ?? 0) + e.cores * (e.at - now))
|
|
517
|
+
let end = last
|
|
518
|
+
for (const [p, w] of work) end = Math.max(end, now + w / Math.max(cores.get(p) ?? 0, 1e-9))
|
|
519
|
+
for (const t of ready.live()) end = Math.max(end, now + tailOf(t.id))
|
|
520
|
+
for (const e of ends.all())
|
|
521
|
+
for (const d of (e.place >= 0 ? afterAt(e.place) : after.get(e.id!)) ?? [])
|
|
522
|
+
end = Math.max(end, e.at + tailAt(d))
|
|
523
|
+
return { end, cut: true, first: first(), ends: byPlatform(), free }
|
|
524
|
+
}
|
|
403
525
|
if (next === Infinity)
|
|
404
|
-
return { end: left === 0 ? last : Infinity, first: first(), ends: byPlatform }
|
|
526
|
+
return { end: left === 0 ? last : Infinity, first: first(), ends: byPlatform(), free }
|
|
405
527
|
now = next
|
|
406
|
-
|
|
407
|
-
const
|
|
408
|
-
if (at > now) {
|
|
409
|
-
i++
|
|
410
|
-
continue
|
|
411
|
-
}
|
|
412
|
-
ends.splice(i, 1)
|
|
528
|
+
while (ends.next() <= now) {
|
|
529
|
+
const { s, cores, memory, place, id } = ends.pop()
|
|
413
530
|
s.used.cores -= cores
|
|
414
531
|
s.used.memory -= memory
|
|
415
532
|
s.running--
|
|
416
|
-
if (
|
|
417
|
-
if (
|
|
418
|
-
for (const d of
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
if (n === 0) {
|
|
422
|
-
joining.push(byId.get(d)!)
|
|
533
|
+
if (place >= 0) left--
|
|
534
|
+
else if (id === undefined) continue
|
|
535
|
+
for (const d of (place >= 0 ? afterAt(place) : after.get(id!)) ?? []) {
|
|
536
|
+
if (--waits[d]! === 0) {
|
|
537
|
+
joining.push(tasks[d]!)
|
|
423
538
|
readyCount++
|
|
424
|
-
count(
|
|
539
|
+
count(tasks[d]!, 1)
|
|
425
540
|
}
|
|
426
541
|
}
|
|
427
542
|
}
|
|
428
543
|
}
|
|
429
544
|
}
|
|
430
545
|
|
|
546
|
+
const NONE: ReadonlyMap<string, Slot> = new Map()
|
|
547
|
+
|
|
548
|
+
interface End {
|
|
549
|
+
at: number
|
|
550
|
+
seq: number
|
|
551
|
+
s: Slot
|
|
552
|
+
cores: number
|
|
553
|
+
memory: number
|
|
554
|
+
/** The task's place in the play, or -1 for one running when it began (`id`). */
|
|
555
|
+
place: number
|
|
556
|
+
id: string | undefined
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* A play's ends to come, soonest first, ties in the order given: a scan
|
|
561
|
+
* of every running task at each step was most of a 20 000-task play
|
|
562
|
+
* (2026-11-05).
|
|
563
|
+
*/
|
|
564
|
+
class Ends {
|
|
565
|
+
private readonly heap: End[] = []
|
|
566
|
+
private seq = 0
|
|
567
|
+
private readonly before = (a: End, b: End): boolean =>
|
|
568
|
+
a.at < b.at || (a.at === b.at && a.seq < b.seq)
|
|
569
|
+
push(
|
|
570
|
+
at: number,
|
|
571
|
+
s: Slot,
|
|
572
|
+
cores: number,
|
|
573
|
+
memory: number,
|
|
574
|
+
place: number,
|
|
575
|
+
id: string | undefined,
|
|
576
|
+
): void {
|
|
577
|
+
const h = this.heap
|
|
578
|
+
const e: End = { at, seq: this.seq++, s, cores, memory, place, id }
|
|
579
|
+
let i = h.length
|
|
580
|
+
h.push(e)
|
|
581
|
+
while (i > 0) {
|
|
582
|
+
const p = (i - 1) >> 1
|
|
583
|
+
if (!this.before(e, h[p]!)) break
|
|
584
|
+
h[i] = h[p]!
|
|
585
|
+
i = p
|
|
586
|
+
}
|
|
587
|
+
h[i] = e
|
|
588
|
+
}
|
|
589
|
+
next(): number {
|
|
590
|
+
return this.heap[0]?.at ?? Infinity
|
|
591
|
+
}
|
|
592
|
+
all(): readonly End[] {
|
|
593
|
+
return this.heap
|
|
594
|
+
}
|
|
595
|
+
last(): number {
|
|
596
|
+
let most = 0
|
|
597
|
+
for (const e of this.heap) most = Math.max(most, e.at)
|
|
598
|
+
return most
|
|
599
|
+
}
|
|
600
|
+
pop(): End {
|
|
601
|
+
const h = this.heap
|
|
602
|
+
const top = h[0]!
|
|
603
|
+
const e = h.pop()!
|
|
604
|
+
if (h.length > 0) {
|
|
605
|
+
let i = 0
|
|
606
|
+
for (;;) {
|
|
607
|
+
const l = 2 * i + 1
|
|
608
|
+
if (l >= h.length) break
|
|
609
|
+
const c = l + 1 < h.length && this.before(h[l + 1]!, h[l]!) ? l + 1 : l
|
|
610
|
+
if (!this.before(h[c]!, e)) break
|
|
611
|
+
h[i] = h[c]!
|
|
612
|
+
i = c
|
|
613
|
+
}
|
|
614
|
+
h[i] = e
|
|
615
|
+
}
|
|
616
|
+
return top
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/** What `play` reads of `tasks` alone, for each play of one plan. */
|
|
621
|
+
interface Shape {
|
|
622
|
+
readonly byId: { get(id: string): PlanTask | undefined }
|
|
623
|
+
/** Each running task's dependants, by their place in the tasks. */
|
|
624
|
+
readonly after: ReadonlyMap<string, readonly number[]>
|
|
625
|
+
/** Each task's dependants by its place, and its place by id: a lookup by id at each end was most of a play. */
|
|
626
|
+
readonly afterAt: (place: number) => Int32Array
|
|
627
|
+
readonly indexOf: { get(id: string): number | undefined }
|
|
628
|
+
/** How many dependencies each task, by its place, waits on before the play begins. */
|
|
629
|
+
readonly waitedAt: Int32Array
|
|
630
|
+
readonly tailOf: (id: string) => number
|
|
631
|
+
readonly tailAt: (place: number) => number
|
|
632
|
+
readonly kinds: Kinds
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* `tasks`' shape, made once while what runs stays the same: a plan plays
|
|
637
|
+
* them 20 to 40 times, and each play made it again, its tails and kinds
|
|
638
|
+
* too (2026-11-05).
|
|
639
|
+
*/
|
|
640
|
+
function shapeOf(tasks: readonly PlanTask[], running: ReadonlySet<string>): Shape {
|
|
641
|
+
const runs = [...running].sort().join('\0')
|
|
642
|
+
const had = shapes.get(tasks)
|
|
643
|
+
if (had !== undefined && had.runs === runs) return had.shape
|
|
644
|
+
// Of a running task only, by id: one in the work is by its place.
|
|
645
|
+
const after = new Map<string, number[]>()
|
|
646
|
+
const waitedAt = new Int32Array(tasks.length)
|
|
647
|
+
const first = tasks[0]?.depsN
|
|
648
|
+
const num =
|
|
649
|
+
first !== undefined && tasks.every((t) => t.depsN !== undefined)
|
|
650
|
+
? numberedOf.get(first)
|
|
651
|
+
: undefined
|
|
652
|
+
let edges = 0
|
|
653
|
+
for (let i = 0; i < tasks.length; i++) edges += tasks[i]!.deps.length
|
|
654
|
+
// Each dependency's place in the work, -1 when not in it.
|
|
655
|
+
const depAt = new Int32Array(edges)
|
|
656
|
+
let indexOf: { get(id: string): number | undefined }
|
|
657
|
+
if (num !== undefined) {
|
|
658
|
+
// By the run's numbers, not ids: a map of a million ids, made and
|
|
659
|
+
// read at each plan, was most of its time (2026-11-06).
|
|
660
|
+
const place = new Int32Array(num.ids.length).fill(-1)
|
|
661
|
+
for (let i = 0; i < tasks.length; i++) place[tasks[i]!.n!] = i
|
|
662
|
+
// Indexed: iterators over a million tasks' deps were a third of the shape.
|
|
663
|
+
for (let e = 0, i = 0; i < tasks.length; i++) {
|
|
664
|
+
const ds = tasks[i]!.depsN!
|
|
665
|
+
for (let k = 0; k < ds.length; k++) depAt[e++] = place[ds[k]!]!
|
|
666
|
+
}
|
|
667
|
+
indexOf = {
|
|
668
|
+
get: (id) => {
|
|
669
|
+
const n = num.of.get(id)?.n
|
|
670
|
+
const j = n === undefined ? -1 : place[n]!
|
|
671
|
+
return j < 0 ? undefined : j
|
|
672
|
+
},
|
|
673
|
+
}
|
|
674
|
+
} else {
|
|
675
|
+
// One map by id, the rest by place: a map of each by id made a
|
|
676
|
+
// 10 000-task plan's shape a third of its time (2026-11-05).
|
|
677
|
+
const of = new Map<string, number>()
|
|
678
|
+
for (let i = 0; i < tasks.length; i++) of.set(tasks[i]!.id, i)
|
|
679
|
+
let e = 0
|
|
680
|
+
for (const t of tasks) for (const d of t.deps) depAt[e++] = of.get(d) ?? -1
|
|
681
|
+
indexOf = of
|
|
682
|
+
}
|
|
683
|
+
// Each task's dependants in one array, by place: an array each, pushed
|
|
684
|
+
// to, was most of a million-task shape (2026-11-06).
|
|
685
|
+
const start = new Int32Array(tasks.length + 1)
|
|
686
|
+
for (let e = 0, i = 0; i < tasks.length; i++) {
|
|
687
|
+
const deps = tasks[i]!.deps
|
|
688
|
+
let n = 0
|
|
689
|
+
for (let k = 0; k < deps.length; k++, e++) {
|
|
690
|
+
const j = depAt[e]!
|
|
691
|
+
if (j >= 0) {
|
|
692
|
+
start[j + 1]!++
|
|
693
|
+
n++
|
|
694
|
+
} else if (running.has(deps[k]!)) {
|
|
695
|
+
n++
|
|
696
|
+
const at = after.get(deps[k]!)
|
|
697
|
+
if (at === undefined) after.set(deps[k]!, [i])
|
|
698
|
+
else at.push(i)
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
waitedAt[i] = n
|
|
702
|
+
}
|
|
703
|
+
for (let i = 0; i < tasks.length; i++) start[i + 1]! += start[i]!
|
|
704
|
+
const list = new Int32Array(start[tasks.length]!)
|
|
705
|
+
const fill = start.slice(0, tasks.length)
|
|
706
|
+
for (let e = 0, i = 0; i < tasks.length; i++)
|
|
707
|
+
for (let k = tasks[i]!.deps.length; k > 0; k--, e++) {
|
|
708
|
+
const j = depAt[e]!
|
|
709
|
+
if (j >= 0) list[fill[j]!++] = i
|
|
710
|
+
}
|
|
711
|
+
const afterAt = (i: number): Int32Array => list.subarray(start[i]!, start[i + 1]!)
|
|
712
|
+
const tail = new Float64Array(tasks.length).fill(-1)
|
|
713
|
+
const tailAt = (i: number): number => {
|
|
714
|
+
if (tail[i]! >= 0) return tail[i]!
|
|
715
|
+
let most = 0
|
|
716
|
+
for (let k = start[i]!; k < start[i + 1]!; k++) most = Math.max(most, tailAt(list[k]!))
|
|
717
|
+
return (tail[i] = most + (tasks[i]!.ms ?? UNKNOWN_MS))
|
|
718
|
+
}
|
|
719
|
+
const tailOf = (id: string): number => tailAt(indexOf.get(id)!)
|
|
720
|
+
const byId = { get: (id: string) => tasks[indexOf.get(id)!] }
|
|
721
|
+
const shape = {
|
|
722
|
+
byId,
|
|
723
|
+
after,
|
|
724
|
+
afterAt,
|
|
725
|
+
indexOf,
|
|
726
|
+
waitedAt,
|
|
727
|
+
tailOf,
|
|
728
|
+
tailAt,
|
|
729
|
+
kinds: { at: indexOf, kindAt: lazyKinds(tasks) },
|
|
730
|
+
}
|
|
731
|
+
shapes.set(tasks, { runs, shape })
|
|
732
|
+
return shape
|
|
733
|
+
}
|
|
734
|
+
const shapes = new WeakMap<readonly PlanTask[], { runs: string; shape: Shape }>()
|
|
735
|
+
|
|
736
|
+
/** Each task's place in `tasks` and its kind (`kindOf`). */
|
|
737
|
+
interface Kinds {
|
|
738
|
+
readonly at: { get(id: string): number | undefined }
|
|
739
|
+
readonly kindAt: (place: number) => string
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Each task's kind, made as a play first reads it: made for every task,
|
|
744
|
+
* a million float-to-string keys were a fifth of a plan, and a play
|
|
745
|
+
* windowed to two warm-ups reads few of them (2026-11-06).
|
|
746
|
+
*/
|
|
747
|
+
function lazyKinds(tasks: readonly PlanTask[]): (place: number) => string {
|
|
748
|
+
const kinds: (string | undefined)[] = []
|
|
749
|
+
return (i) => (kinds[i] ??= kindOf(tasks[i]!))
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function kindsOf(tasks: readonly PlanTask[]): Kinds {
|
|
753
|
+
return { at: new Map(tasks.map((t, i) => [t.id, i])), kindAt: lazyKinds(tasks) }
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* No play of `tasks` ends sooner: each chain of them in a row, from when
|
|
758
|
+
* the running tasks it waits on end, and each running task's end.
|
|
759
|
+
*/
|
|
760
|
+
function chainMs(tasks: readonly PlanTask[], workers: readonly PlanWorker[]): number {
|
|
761
|
+
const runningIds = new Set<string>()
|
|
762
|
+
const endsAt = new Map<string, number>()
|
|
763
|
+
for (const w of workers)
|
|
764
|
+
for (const r of w.running)
|
|
765
|
+
if (r.id !== undefined) {
|
|
766
|
+
runningIds.add(r.id)
|
|
767
|
+
endsAt.set(r.id, Math.max(endsAt.get(r.id) ?? 0, r.endsIn))
|
|
768
|
+
}
|
|
769
|
+
const { tailAt, after } = shapeOf(tasks, runningIds)
|
|
770
|
+
let most = 0
|
|
771
|
+
for (const w of workers) for (const r of w.running) most = Math.max(most, r.endsIn)
|
|
772
|
+
// From the running tasks' dependants: each task's deps read by id was
|
|
773
|
+
// most of a million-task plan's bound (2026-11-06).
|
|
774
|
+
const from = new Float64Array(tasks.length)
|
|
775
|
+
for (const [id, ds] of after)
|
|
776
|
+
for (const i of ds) from[i] = Math.max(from[i]!, endsAt.get(id) ?? 0)
|
|
777
|
+
for (let i = 0; i < tasks.length; i++) most = Math.max(most, from[i]! + tailAt(i))
|
|
778
|
+
return most
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/** Two workers a play cannot tell apart, but by their place. */
|
|
782
|
+
const twins = (a: PlanWorker, b: PlanWorker): boolean =>
|
|
783
|
+
a.cores === b.cores && a.memory === b.memory && a.platform === b.platform && a.at === b.at
|
|
784
|
+
|
|
785
|
+
/** A worker of `s` that joins `at`. */
|
|
786
|
+
function fresh(
|
|
787
|
+
s: { cpu: number; memory: number; platform?: string | undefined; size?: string | undefined },
|
|
788
|
+
id: string,
|
|
789
|
+
at: number,
|
|
790
|
+
): PlanWorker {
|
|
791
|
+
return {
|
|
792
|
+
id,
|
|
793
|
+
cores: s.cpu,
|
|
794
|
+
memory: s.memory,
|
|
795
|
+
platform: s.platform,
|
|
796
|
+
...(s.size === undefined ? {} : { size: s.size }),
|
|
797
|
+
at,
|
|
798
|
+
running: [],
|
|
799
|
+
idle: false,
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
431
803
|
/**
|
|
432
804
|
* The workers to start and to let go. The fewest new ones (`candidates`,
|
|
433
805
|
* in the order the run would start them, each joining after its size's `warmUpMs`)
|
|
@@ -449,30 +821,43 @@ export function plan(
|
|
|
449
821
|
// The latest an earlier plan of the same work allowed, from now.
|
|
450
822
|
limitMs = Infinity,
|
|
451
823
|
how: Strategy = DEFAULT_STRATEGY,
|
|
452
|
-
): Plan {
|
|
824
|
+
): Plan & Ahead {
|
|
453
825
|
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
826
|
const added = candidates.map((s, i) => fresh({ ...s, size: s.name }, `\0new${i}`, warm(s.name)))
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
827
|
+
// The fewest new ones ending within the margin of the best, found by
|
|
828
|
+
// halving: more workers end the run no later, so the best is all of
|
|
829
|
+
// them. Trying each count in turn played the whole run some 50 times a
|
|
830
|
+
// plan (2026-11-05: "Why it replays 64 times to pick workers").
|
|
831
|
+
// Two warm-ups ahead: a worker asked now joins after one, and the next
|
|
832
|
+
// plan, a warm-up on, sees the one after.
|
|
833
|
+
const horizon =
|
|
834
|
+
2 *
|
|
835
|
+
Math.max(
|
|
836
|
+
warm(undefined),
|
|
837
|
+
...candidates.map((c) => warm(c.name)),
|
|
838
|
+
...workers.map((w) => w.at),
|
|
839
|
+
) +
|
|
840
|
+
MARGIN_MS +
|
|
841
|
+
// And a task given at its end runs through: work counted as cores
|
|
842
|
+
// spread over the rest asked too few workers for a few long tasks.
|
|
843
|
+
tasks.reduce((most, t) => Math.max(most, t.ms ?? UNKNOWN_MS), 0)
|
|
844
|
+
// Each set of workers played once a plan: the count search, the drop
|
|
845
|
+
// pass, the releases and the final play each played the same set again.
|
|
846
|
+
const plays = new Map<string, ReturnType<typeof play>>()
|
|
847
|
+
const run = (ws: readonly PlanWorker[]): ReturnType<typeof play> => {
|
|
848
|
+
const key = ws.map((w) => `${w.id}\0${w.at}`).join('\n')
|
|
849
|
+
let p = plays.get(key)
|
|
850
|
+
if (p === undefined) plays.set(key, (p = play(tasks, ws, how, horizon)))
|
|
851
|
+
return p
|
|
474
852
|
}
|
|
475
|
-
|
|
853
|
+
// Given no task in the play, and none past its window.
|
|
854
|
+
const unused = (p: ReturnType<typeof play>, id: string): boolean =>
|
|
855
|
+
!p.first.has(id) && p.cut !== true
|
|
856
|
+
const playOf = (n: number): ReturnType<typeof play> => run([...workers, ...added.slice(0, n)])
|
|
857
|
+
const endOf = (n: number): number => playOf(n).end
|
|
858
|
+
const floor = chainMs(tasks, workers)
|
|
859
|
+
const none = endOf(0)
|
|
860
|
+
const best = none <= floor ? none : Math.min(none, endOf(candidates.length))
|
|
476
861
|
// Margin spent at each decision adds up: releases 4 s apart each lost
|
|
477
862
|
// 4 s of 5 (tests/sim.test.ts, a growing graph). Spent once, unless the
|
|
478
863
|
// rest can no longer end by then.
|
|
@@ -480,10 +865,15 @@ export function plan(
|
|
|
480
865
|
best === Infinity
|
|
481
866
|
? Infinity
|
|
482
867
|
: Math.max(best, Math.min(best + Math.max(MARGIN_MS, best * MARGIN), limitMs))
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
)
|
|
868
|
+
let lo = 0
|
|
869
|
+
let hi = candidates.length
|
|
870
|
+
if (none <= bound) hi = 0
|
|
871
|
+
while (lo < hi) {
|
|
872
|
+
const mid = (lo + hi) >> 1
|
|
873
|
+
if (endOf(mid) <= bound) hi = mid
|
|
874
|
+
else lo = mid + 1
|
|
875
|
+
}
|
|
876
|
+
const n = lo
|
|
487
877
|
let kept = [...workers, ...added.slice(0, n)]
|
|
488
878
|
// Sizes come in turns, so the prefix can hold one the plan ends as soon
|
|
489
879
|
// without: a second Mac the plan needed came after a second Linux worker
|
|
@@ -491,16 +881,26 @@ export function plan(
|
|
|
491
881
|
// Dropped only when neither the end nor its platform's moves past the
|
|
492
882
|
// margin: to the run's end alone, a 110 s guess of an unmeasured chain
|
|
493
883
|
// cut every other platform to it.
|
|
494
|
-
let before = best < Infinity && n > 0 ?
|
|
884
|
+
let before = best < Infinity && n > 0 ? playOf(n) : undefined
|
|
495
885
|
if (before !== undefined)
|
|
496
|
-
for (let i = n - 1; i >= 0; i--) {
|
|
886
|
+
for (let i = n - 1, dropped = false; i >= 0; i--) {
|
|
887
|
+
// Its twin after it stayed, and nothing moved since: without this
|
|
888
|
+
// one the play is that one's again, so it stays too. Each was a
|
|
889
|
+
// play of the whole run (2026-11-05).
|
|
890
|
+
if (i < n - 1 && !dropped && twins(added[i]!, added[i + 1]!)) continue
|
|
497
891
|
const platform = added[i]!.platform
|
|
498
892
|
const without = kept.filter((k) => k !== added[i])
|
|
499
|
-
|
|
500
|
-
if (
|
|
893
|
+
// Given no task in a whole play, the play without it is the same.
|
|
894
|
+
if (unused(before, added[i]!.id)) {
|
|
895
|
+
kept = without
|
|
896
|
+
dropped = true
|
|
897
|
+
continue
|
|
898
|
+
}
|
|
899
|
+
const after = run(without)
|
|
900
|
+
dropped =
|
|
501
901
|
after.end <= before.end &&
|
|
502
902
|
(after.ends.get(platform) ?? 0) <= (before.ends.get(platform) ?? 0) + MARGIN_MS
|
|
503
|
-
) {
|
|
903
|
+
if (dropped) {
|
|
504
904
|
kept = without
|
|
505
905
|
before = after
|
|
506
906
|
}
|
|
@@ -528,11 +928,7 @@ export function plan(
|
|
|
528
928
|
// margin, the next plan's estimates moved a second later and
|
|
529
929
|
// cancelled 7 asked one at a time (PR #4214); ending the run no
|
|
530
930
|
// 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
|
-
) {
|
|
931
|
+
if (twin === undefined ? unused(run(kept), w.id) : run(without).end <= bound) {
|
|
536
932
|
kept = without
|
|
537
933
|
if (twin === undefined) release.push(w.id)
|
|
538
934
|
}
|
|
@@ -552,7 +948,9 @@ export function plan(
|
|
|
552
948
|
})
|
|
553
949
|
for (const w of idle) {
|
|
554
950
|
const without = kept.filter((k) => k !== w)
|
|
555
|
-
const
|
|
951
|
+
const played = run([...kept, ...standIns])
|
|
952
|
+
// Given nothing in a window with work past it: needed past it, maybe.
|
|
953
|
+
const next = played.first.get(w.id) ?? (played.cut === true ? horizon : undefined)
|
|
556
954
|
if (next === undefined) {
|
|
557
955
|
kept = without
|
|
558
956
|
release.push(w.id)
|
|
@@ -571,7 +969,7 @@ export function plan(
|
|
|
571
969
|
w.id,
|
|
572
970
|
next,
|
|
573
971
|
)
|
|
574
|
-
if (
|
|
972
|
+
if (run([...without, ...standIns, instead]).end <= bound) {
|
|
575
973
|
kept = without
|
|
576
974
|
release.push(w.id)
|
|
577
975
|
standIns.push(instead)
|
|
@@ -579,20 +977,43 @@ export function plan(
|
|
|
579
977
|
}
|
|
580
978
|
}
|
|
581
979
|
}
|
|
582
|
-
const { end, first } =
|
|
980
|
+
const { end, first, free, cut } = run([...kept, ...standIns])
|
|
583
981
|
const start: Size[] = []
|
|
982
|
+
const frees = free()
|
|
983
|
+
const started: { freeInMs: number; joinInMs: number }[] = []
|
|
984
|
+
const later: { size: Size; inMs: number; freeInMs: number; joinInMs: number }[] = []
|
|
584
985
|
let againInMs: number | undefined
|
|
585
986
|
for (let i = 0; i < n; i++) {
|
|
586
|
-
const
|
|
987
|
+
const id = added[i]!.id
|
|
988
|
+
const at = first.get(id)
|
|
587
989
|
if (at === undefined) continue
|
|
588
990
|
// Given work within its warm-up and a margin: it starts now.
|
|
589
991
|
const warmUp = added[i]!.at
|
|
590
992
|
// Within a millisecond counts: `againInMs` of 1e-10 replanned the
|
|
591
993
|
// same plan 100,000 times at one instant (tests/sim.test.ts).
|
|
592
|
-
if (at < warmUp + MARGIN_MS + 1)
|
|
593
|
-
|
|
994
|
+
if (at < warmUp + MARGIN_MS + 1) {
|
|
995
|
+
start.push(candidates[i]!)
|
|
996
|
+
started.push({ freeInMs: frees.get(id)!, joinInMs: warmUp })
|
|
997
|
+
} else {
|
|
998
|
+
const inMs = at - warmUp - MARGIN_MS
|
|
999
|
+
againInMs = Math.min(againInMs ?? Infinity, inMs)
|
|
1000
|
+
later.push({ size: candidates[i]!, inMs, freeInMs: frees.get(id)!, joinInMs: warmUp })
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
later.sort((a, b) => a.inMs - b.inMs)
|
|
1004
|
+
// Work past the window: plan again a warm-up on, the window moved.
|
|
1005
|
+
const untilInMs = cut === true ? (horizon - MARGIN_MS) / 2 : undefined
|
|
1006
|
+
return {
|
|
1007
|
+
start,
|
|
1008
|
+
release,
|
|
1009
|
+
endsInMs: end,
|
|
1010
|
+
boundMs: bound,
|
|
1011
|
+
againInMs: untilInMs === undefined ? againInMs : Math.min(againInMs ?? Infinity, untilInMs),
|
|
1012
|
+
untilInMs,
|
|
1013
|
+
freeInMs: frees,
|
|
1014
|
+
later,
|
|
1015
|
+
started,
|
|
594
1016
|
}
|
|
595
|
-
return { start, release, endsInMs: end, boundMs: bound, againInMs }
|
|
596
1017
|
}
|
|
597
1018
|
|
|
598
1019
|
/**
|
|
@@ -676,8 +1097,35 @@ export function decide(input: {
|
|
|
676
1097
|
* queued it, and one asked now queues behind it. None of them starts.
|
|
677
1098
|
*/
|
|
678
1099
|
readonly queued?: ReadonlySet<string>
|
|
1100
|
+
/**
|
|
1101
|
+
* The run's last plan, kept here: while what it saw holds, the next is
|
|
1102
|
+
* that plan again, not played anew, and hands back no `tasks` when it
|
|
1103
|
+
* read none. With `now`, the run's clock.
|
|
1104
|
+
*/
|
|
1105
|
+
readonly last?: { forecast?: Forecast }
|
|
1106
|
+
readonly now?: number
|
|
679
1107
|
}): Plan & { readonly tasks: readonly PlanTask[] } {
|
|
680
1108
|
const how = input.how ?? DEFAULT_STRATEGY
|
|
1109
|
+
const now = input.now ?? 0
|
|
1110
|
+
const had = input.last?.forecast
|
|
1111
|
+
// Following a plan reads only what events move: the workers, their
|
|
1112
|
+
// joins, each task as it starts and ends. Every task read at each of a
|
|
1113
|
+
// 5000-task run's events made it quadratic (2026-11-05: "Why do we
|
|
1114
|
+
// recheck every task against plan? There is no point").
|
|
1115
|
+
if (how.scale !== 'backlog' && had !== undefined) {
|
|
1116
|
+
const step = follow(had, undefined, input, undefined, now)
|
|
1117
|
+
// No work read, none to hand back: each step handed back the plan's
|
|
1118
|
+
// whole work, and the run read it all at each event.
|
|
1119
|
+
// A start or a release hands back the work it plans for, as the run reports it.
|
|
1120
|
+
if (step !== undefined && step !== 'full')
|
|
1121
|
+
return shown(
|
|
1122
|
+
input.last!,
|
|
1123
|
+
had,
|
|
1124
|
+
step,
|
|
1125
|
+
now,
|
|
1126
|
+
step.start.length > 0 || step.release.length > 0 ? had.work : [],
|
|
1127
|
+
)
|
|
1128
|
+
}
|
|
681
1129
|
const tasks = missedWork(
|
|
682
1130
|
input.remaining,
|
|
683
1131
|
input.nodes,
|
|
@@ -702,7 +1150,9 @@ export function decide(input: {
|
|
|
702
1150
|
)
|
|
703
1151
|
if (how.scale === 'backlog')
|
|
704
1152
|
return { ...backlog(tasks, input.workers, sizes, input.warmUpMs, how), tasks }
|
|
705
|
-
const
|
|
1153
|
+
const step = had === undefined ? undefined : follow(had, tasks, input, sizes, now)
|
|
1154
|
+
if (step !== undefined && step !== 'full') return shown(input.last!, had!, step, now, tasks)
|
|
1155
|
+
const { freeInMs, later, started, untilInMs, ...p } = plan(
|
|
706
1156
|
tasks,
|
|
707
1157
|
input.workers,
|
|
708
1158
|
sizes,
|
|
@@ -712,9 +1162,341 @@ export function decide(input: {
|
|
|
712
1162
|
tasks.every((t) => input.known?.has(t.id) === true) ? input.limitMs : undefined,
|
|
713
1163
|
how,
|
|
714
1164
|
)
|
|
1165
|
+
if (input.last !== undefined)
|
|
1166
|
+
input.last.forecast = forecastOf(p, { freeInMs, later, started, untilInMs }, tasks, input, now)
|
|
715
1167
|
return { ...p, tasks }
|
|
716
1168
|
}
|
|
717
1169
|
|
|
1170
|
+
/**
|
|
1171
|
+
* What a plan saw, to tell whether the next would differ: its work and
|
|
1172
|
+
* each task's time, its workers as starting, idle or busy, the tasks
|
|
1173
|
+
* already past their time, the sizes queued, when it wanted to look again.
|
|
1174
|
+
*/
|
|
1175
|
+
export interface Forecast {
|
|
1176
|
+
readonly at: number
|
|
1177
|
+
readonly plan: Plan
|
|
1178
|
+
/** The work it played. */
|
|
1179
|
+
readonly work: readonly PlanTask[]
|
|
1180
|
+
/** Each waiting task's time, and each waiting or running task's cores and memory. */
|
|
1181
|
+
readonly ms: ReadonlyMap<string, number | undefined>
|
|
1182
|
+
readonly need: ReadonlyMap<string, readonly [cores: number, memory: number]>
|
|
1183
|
+
/** The workers it saw, and those it started as they show. */
|
|
1184
|
+
readonly workers: ReadonlySet<string>
|
|
1185
|
+
/** How many workers the run has as planned. */
|
|
1186
|
+
readonly count: number
|
|
1187
|
+
/** When each worker's last planned task ends, on the run's clock. */
|
|
1188
|
+
readonly free: ReadonlyMap<string, number>
|
|
1189
|
+
/** When each worker still starting joins, on the run's clock. */
|
|
1190
|
+
readonly join: ReadonlyMap<string, number>
|
|
1191
|
+
/** Those of each worker started and not seen yet, in the order started. */
|
|
1192
|
+
readonly fresh: readonly { readonly free: number; readonly join: number }[]
|
|
1193
|
+
/** Starts the plan wants later, each when due, on the run's clock. */
|
|
1194
|
+
readonly later: readonly {
|
|
1195
|
+
readonly size: Size
|
|
1196
|
+
readonly at: number
|
|
1197
|
+
readonly free: number
|
|
1198
|
+
readonly join: number
|
|
1199
|
+
}[]
|
|
1200
|
+
/** Work it saw that has since been seen running: work that left otherwise moves the plan. */
|
|
1201
|
+
readonly seen: Set<string>
|
|
1202
|
+
/** When each task seen running ends, on the run's clock. */
|
|
1203
|
+
readonly ends: ReadonlyMap<string, number>
|
|
1204
|
+
/** How many tasks the run had handed over, and those it had when planned. */
|
|
1205
|
+
readonly executed: number
|
|
1206
|
+
readonly before: ReadonlySet<string>
|
|
1207
|
+
/**
|
|
1208
|
+
* How far it read the handed-over set, kept across its steps: a Set's
|
|
1209
|
+
* iterator sees what is added after it, so each id is read once. Each
|
|
1210
|
+
* step read the whole set, O(n) an event (2026-11-05).
|
|
1211
|
+
*/
|
|
1212
|
+
readonly scan: { of?: ReadonlySet<string> | undefined; it?: Iterator<string>; read: number }
|
|
1213
|
+
/** Each size's warm-up as it saw it: a join tells the run's own. */
|
|
1214
|
+
readonly warm: readonly number[]
|
|
1215
|
+
/** When its window moves on, on the run's clock. */
|
|
1216
|
+
readonly until: number
|
|
1217
|
+
readonly late: ReadonlySet<string>
|
|
1218
|
+
readonly queued: string
|
|
1219
|
+
readonly final: boolean
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
type Seen = Pick<
|
|
1223
|
+
Parameters<typeof decide>[0],
|
|
1224
|
+
'workers' | 'queued' | 'final' | 'sizes' | 'started' | 'warmUpMs' | 'executed' | 'nodes'
|
|
1225
|
+
>
|
|
1226
|
+
|
|
1227
|
+
/**
|
|
1228
|
+
* Running tasks past their time by more than `slack`: one that runs that
|
|
1229
|
+
* long moves the plan (2026-11-05: "Reality changes by leave some
|
|
1230
|
+
* margin. 5% or sth").
|
|
1231
|
+
*/
|
|
1232
|
+
function lateOf(workers: readonly PlanWorker[], slack: number): Set<string> {
|
|
1233
|
+
const out = new Set<string>()
|
|
1234
|
+
for (const w of workers)
|
|
1235
|
+
for (const r of w.running) if (r.endsIn < -slack && r.id !== undefined) out.add(r.id)
|
|
1236
|
+
return out
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
/** Each waiting and running task's cores and memory, as a plan saw them. */
|
|
1240
|
+
function needsOf(
|
|
1241
|
+
tasks: readonly PlanTask[],
|
|
1242
|
+
workers: readonly PlanWorker[],
|
|
1243
|
+
): Map<string, readonly [number, number]> {
|
|
1244
|
+
const out = new Map<string, readonly [number, number]>(
|
|
1245
|
+
tasks.map((t) => [t.id, [t.cores, t.memory] as const]),
|
|
1246
|
+
)
|
|
1247
|
+
for (const w of workers)
|
|
1248
|
+
for (const r of w.running) if (r.id !== undefined) out.set(r.id, [r.cores, r.memory])
|
|
1249
|
+
return out
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
/** Whether `is` moved from `was` past 5%, or past `least` when they are small. */
|
|
1253
|
+
const moved = (was: number, is: number, least: number): boolean =>
|
|
1254
|
+
Math.abs(is - was) > Math.max(least, Math.abs(was) * MARGIN)
|
|
1255
|
+
|
|
1256
|
+
/** How late a task may run, or how far its time may move, before the plan is made again: 5% of the run's. */
|
|
1257
|
+
const slackOf = (p: Plan): number => Math.max(MARGIN_MS / 5, p.endsInMs * MARGIN)
|
|
1258
|
+
|
|
1259
|
+
const warmsOf = (input: Seen): number[] => {
|
|
1260
|
+
const warm = warmOf(input.warmUpMs)
|
|
1261
|
+
return [warm(undefined), ...input.sizes.map((s) => warm(s.name))]
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
function forecastOf(
|
|
1265
|
+
p: Plan,
|
|
1266
|
+
ahead: Ahead,
|
|
1267
|
+
tasks: readonly PlanTask[],
|
|
1268
|
+
input: Seen,
|
|
1269
|
+
now: number,
|
|
1270
|
+
): Forecast {
|
|
1271
|
+
return {
|
|
1272
|
+
at: now,
|
|
1273
|
+
plan: p,
|
|
1274
|
+
work: tasks,
|
|
1275
|
+
ms: new Map(tasks.map((t) => [t.id, t.ms])),
|
|
1276
|
+
need: needsOf(tasks, input.workers),
|
|
1277
|
+
workers: new Set(input.workers.map((w) => w.id).filter((id) => !p.release.includes(id))),
|
|
1278
|
+
count: input.workers.length + p.start.length - p.release.length,
|
|
1279
|
+
free: new Map([...ahead.freeInMs].map(([id, ms]) => [id, now + ms])),
|
|
1280
|
+
join: new Map(
|
|
1281
|
+
input.workers.flatMap((w) => (w.starting === true ? [[w.id, now + w.at] as const] : [])),
|
|
1282
|
+
),
|
|
1283
|
+
fresh: ahead.started.map((a) => ({ free: now + a.freeInMs, join: now + a.joinInMs })),
|
|
1284
|
+
later: ahead.later.map((l) => ({
|
|
1285
|
+
size: l.size,
|
|
1286
|
+
at: now + l.inMs,
|
|
1287
|
+
free: now + l.freeInMs,
|
|
1288
|
+
join: now + l.inMs + l.joinInMs,
|
|
1289
|
+
})),
|
|
1290
|
+
seen: new Set(),
|
|
1291
|
+
ends: new Map(
|
|
1292
|
+
input.workers.flatMap((w) =>
|
|
1293
|
+
w.running.flatMap((r) => (r.id === undefined ? [] : [[r.id, now + r.endsIn] as const])),
|
|
1294
|
+
),
|
|
1295
|
+
),
|
|
1296
|
+
executed: input.executed.size,
|
|
1297
|
+
before: new Set(input.executed),
|
|
1298
|
+
scan: { read: 0 },
|
|
1299
|
+
warm: warmsOf(input),
|
|
1300
|
+
until: ahead.untilInMs === undefined ? Infinity : now + ahead.untilInMs,
|
|
1301
|
+
late: lateOf(input.workers, slackOf(p)),
|
|
1302
|
+
queued: [...(input.queued ?? [])].sort().join('\0'),
|
|
1303
|
+
final: input.final,
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
/** The plan `had` made, followed a step: its decisions now, its times less the time since. */
|
|
1308
|
+
function shown(
|
|
1309
|
+
last: { forecast?: Forecast },
|
|
1310
|
+
had: Forecast,
|
|
1311
|
+
step: Step,
|
|
1312
|
+
now: number,
|
|
1313
|
+
tasks: readonly PlanTask[],
|
|
1314
|
+
): Plan & { readonly tasks: readonly PlanTask[] } {
|
|
1315
|
+
last.forecast = step.next
|
|
1316
|
+
const again = Math.min(step.next.later[0]?.at ?? Infinity, step.next.until)
|
|
1317
|
+
const since = now - had.at
|
|
1318
|
+
return {
|
|
1319
|
+
start: step.start,
|
|
1320
|
+
release: step.release,
|
|
1321
|
+
endsInMs: Math.max(0, had.plan.endsInMs - since),
|
|
1322
|
+
boundMs: had.plan.boundMs - since,
|
|
1323
|
+
againInMs: Number.isFinite(again) ? again - now : undefined,
|
|
1324
|
+
tasks,
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
type Step = { start: Size[]; release: string[]; next: Forecast }
|
|
1329
|
+
|
|
1330
|
+
/**
|
|
1331
|
+
* The plan `f` made, followed one step on when it still stands: no new
|
|
1332
|
+
* work, no task's time, cores or memory moved past 5%, no worker joined
|
|
1333
|
+
* or left but as planned, no task ran past its time. Its starts come due
|
|
1334
|
+
* and its workers with their work done go, as it foresaw; undefined
|
|
1335
|
+
* plays it anew. Planned at every task's end, a run of 1000 tasks
|
|
1336
|
+
* replayed itself 10 000 times (2026-11-05: "It's just about filling
|
|
1337
|
+
* boxes to full"; "If simulated all goes with plan so there should be no
|
|
1338
|
+
* replans").
|
|
1339
|
+
*
|
|
1340
|
+
* Without `tasks` (and `sizes`), only what events move is read: work it
|
|
1341
|
+
* did not see is `full`, read every task first.
|
|
1342
|
+
*/
|
|
1343
|
+
function follow(
|
|
1344
|
+
f: Forecast,
|
|
1345
|
+
tasks: readonly PlanTask[] | undefined,
|
|
1346
|
+
input: Seen,
|
|
1347
|
+
sizes: readonly Size[] | undefined,
|
|
1348
|
+
now: number,
|
|
1349
|
+
): Step | 'full' | undefined {
|
|
1350
|
+
if (now >= f.until) return undefined
|
|
1351
|
+
if (f.final !== input.final) return undefined
|
|
1352
|
+
if ([...(input.queued ?? [])].sort().join('\0') !== f.queued) return undefined
|
|
1353
|
+
if (input.workers.length !== f.count) return undefined
|
|
1354
|
+
const warm = warmsOf(input)
|
|
1355
|
+
for (let i = 0; i < warm.length; i++)
|
|
1356
|
+
if (moved(f.warm[i]!, warm[i]!, MARGIN_MS / 5)) return undefined
|
|
1357
|
+
const slack = slackOf(f.plan)
|
|
1358
|
+
// Only what moved since is read: each task as it starts, runs and ends.
|
|
1359
|
+
// A waiting one's time moves with the run's, which those show.
|
|
1360
|
+
const ends = new Map<string, number>()
|
|
1361
|
+
let began = 0
|
|
1362
|
+
for (const w of input.workers)
|
|
1363
|
+
for (const r of w.running) {
|
|
1364
|
+
if (r.id === undefined) continue
|
|
1365
|
+
const was = f.need.get(r.id)
|
|
1366
|
+
// As for time, so for resources (2026-11-05: "And resource usage").
|
|
1367
|
+
if (
|
|
1368
|
+
was !== undefined &&
|
|
1369
|
+
(moved(was[0], r.cores, 0.05) || moved(was[1], r.memory, 64 * 2 ** 20))
|
|
1370
|
+
)
|
|
1371
|
+
return undefined
|
|
1372
|
+
const end = f.ends.get(r.id)
|
|
1373
|
+
if (end !== undefined) {
|
|
1374
|
+
ends.set(r.id, end)
|
|
1375
|
+
continue
|
|
1376
|
+
}
|
|
1377
|
+
// Started since: work the plan saw, as long as it played it.
|
|
1378
|
+
if (!f.ms.has(r.id)) return undefined
|
|
1379
|
+
const ms = f.ms.get(r.id)
|
|
1380
|
+
if (ms !== undefined && r.endsIn > ms + Math.max(MARGIN_MS / 5, ms * MARGIN)) return undefined
|
|
1381
|
+
ends.set(r.id, now + r.endsIn)
|
|
1382
|
+
f.seen.add(r.id)
|
|
1383
|
+
began++
|
|
1384
|
+
}
|
|
1385
|
+
// Ended since: sooner than played moves the plan, and so does one of
|
|
1386
|
+
// no known time ending.
|
|
1387
|
+
for (const [id, at] of f.ends)
|
|
1388
|
+
if (!ends.has(id) && (now < at - slack || (f.ms.has(id) && f.ms.get(id) === undefined)))
|
|
1389
|
+
return undefined
|
|
1390
|
+
for (const id of lateOf(input.workers, slack)) if (!f.late.has(id)) return undefined
|
|
1391
|
+
// More handed over than started, or fewer known: work it did not see,
|
|
1392
|
+
// maybe. Read it.
|
|
1393
|
+
if (input.executed.size > f.executed + began || input.executed.size < f.executed) {
|
|
1394
|
+
// Handed over and not started yet is work it saw: only one it did not
|
|
1395
|
+
// see sends the plan to read every task.
|
|
1396
|
+
if (tasks === undefined) {
|
|
1397
|
+
if (input.executed.size < f.executed) return 'full'
|
|
1398
|
+
const scan = f.scan
|
|
1399
|
+
if (scan.of !== input.executed) {
|
|
1400
|
+
scan.of = input.executed
|
|
1401
|
+
scan.it = input.executed.values()
|
|
1402
|
+
scan.read = 0
|
|
1403
|
+
}
|
|
1404
|
+
for (; scan.read < input.executed.size; scan.read++) {
|
|
1405
|
+
const r = scan.it!.next()
|
|
1406
|
+
// One left the set: the count no longer says where it read to.
|
|
1407
|
+
if (r.done === true) {
|
|
1408
|
+
scan.of = undefined
|
|
1409
|
+
return 'full'
|
|
1410
|
+
}
|
|
1411
|
+
const id = r.value
|
|
1412
|
+
if (
|
|
1413
|
+
!f.ms.has(id) &&
|
|
1414
|
+
!f.before.has(id) &&
|
|
1415
|
+
input.nodes.get(id)?.config.exec?.command !== undefined
|
|
1416
|
+
) {
|
|
1417
|
+
scan.read++
|
|
1418
|
+
return 'full'
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
} else {
|
|
1422
|
+
for (const t of tasks) if (!f.ms.has(t.id)) return undefined
|
|
1423
|
+
// Work that left without running (a hit, or no longer asked) frees
|
|
1424
|
+
// what the plan gave it.
|
|
1425
|
+
const waiting = new Set(tasks.map((t) => t.id))
|
|
1426
|
+
for (const id of f.ms.keys())
|
|
1427
|
+
if (!waiting.has(id) && !f.seen.has(id) && !ends.has(id)) return undefined
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
const workers = new Set(f.workers)
|
|
1431
|
+
const free = new Map(f.free)
|
|
1432
|
+
const join = new Map(f.join)
|
|
1433
|
+
const fresh = [...f.fresh]
|
|
1434
|
+
const release: string[] = []
|
|
1435
|
+
for (const w of input.workers) {
|
|
1436
|
+
if (!workers.has(w.id)) {
|
|
1437
|
+
// One the plan started, shown: it takes the next started's work.
|
|
1438
|
+
const next = fresh.shift()
|
|
1439
|
+
if (next === undefined) return undefined
|
|
1440
|
+
workers.add(w.id)
|
|
1441
|
+
free.set(w.id, next.free)
|
|
1442
|
+
join.set(w.id, next.join)
|
|
1443
|
+
}
|
|
1444
|
+
// Joined sooner or later than played, past the margin.
|
|
1445
|
+
const joins = join.get(w.id)
|
|
1446
|
+
if (joins !== undefined) {
|
|
1447
|
+
if (w.starting === true) {
|
|
1448
|
+
if (now > joins + slack) return undefined
|
|
1449
|
+
} else {
|
|
1450
|
+
if (now < joins - slack) return undefined
|
|
1451
|
+
join.delete(w.id)
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
// Idle between its tasks, as played; with its work done, it goes.
|
|
1455
|
+
if (!w.idle || w.starting === true) continue
|
|
1456
|
+
const at = free.get(w.id)
|
|
1457
|
+
if (at === undefined || now >= at - slack) {
|
|
1458
|
+
// Its work done in a window with work past it: maybe needed there,
|
|
1459
|
+
// and one let go still counts against its size's max. A Mac let go
|
|
1460
|
+
// so left a Mac task no worker, and the run never ended (#4989).
|
|
1461
|
+
if (f.until < Infinity) return undefined
|
|
1462
|
+
release.push(w.id)
|
|
1463
|
+
workers.delete(w.id)
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
const start: Size[] = []
|
|
1467
|
+
const later = [...f.later]
|
|
1468
|
+
while (later.length > 0 && now >= later[0]!.at) {
|
|
1469
|
+
const { size, free: ends, join: joins } = later.shift()!
|
|
1470
|
+
// Its size taken up since: the plan no longer fits the run. Read
|
|
1471
|
+
// from what the run started, not from every task: each start read
|
|
1472
|
+
// them all (2026-11-05).
|
|
1473
|
+
if (sizes === undefined) {
|
|
1474
|
+
const kind = input.sizes.find((s) => s.name === size.name)
|
|
1475
|
+
if (
|
|
1476
|
+
input.final ||
|
|
1477
|
+
kind === undefined ||
|
|
1478
|
+
input.queued?.has(size.name) === true ||
|
|
1479
|
+
(input.started.get(size.name) ?? 0) >= kind.max
|
|
1480
|
+
)
|
|
1481
|
+
return undefined
|
|
1482
|
+
} else if (!sizes.some((s) => s.name === size.name)) return undefined
|
|
1483
|
+
start.push(size)
|
|
1484
|
+
fresh.push({ free: ends, join: joins })
|
|
1485
|
+
}
|
|
1486
|
+
const next: Forecast = {
|
|
1487
|
+
...f,
|
|
1488
|
+
workers,
|
|
1489
|
+
count: f.count + start.length - release.length,
|
|
1490
|
+
free,
|
|
1491
|
+
join,
|
|
1492
|
+
fresh,
|
|
1493
|
+
later,
|
|
1494
|
+
ends,
|
|
1495
|
+
executed: input.executed.size,
|
|
1496
|
+
}
|
|
1497
|
+
return { start, release, next }
|
|
1498
|
+
}
|
|
1499
|
+
|
|
718
1500
|
/**
|
|
719
1501
|
* Spark's dynamic allocation: workers enough for the cores that what is
|
|
720
1502
|
* ready or running asks, started at once; an idle one past that leaves.
|
|
@@ -794,8 +1576,17 @@ function backlog(
|
|
|
794
1576
|
return { start, release, endsInMs: end, boundMs: end, againInMs: undefined }
|
|
795
1577
|
}
|
|
796
1578
|
|
|
797
|
-
/** The most kinds `Ready.asks` jumps across; past it, it
|
|
1579
|
+
/** The most kinds `Ready.asks` jumps across; past it, it jumps across the cores' classes. */
|
|
798
1580
|
const MAX_JUMP_KINDS = 32
|
|
1581
|
+
/** Classes a doubling of cores spans (`classOf`). */
|
|
1582
|
+
const CLASSES = 4
|
|
1583
|
+
/** The class of a task that names a platform or a size to beat: never passed whole. */
|
|
1584
|
+
const ODD = -Infinity
|
|
1585
|
+
/** A task's cores' class: about 2^(c / CLASSES) to 2^((c + 1) / CLASSES), its span kept as joined. */
|
|
1586
|
+
const classOf = (t: PlanTask): number =>
|
|
1587
|
+
t.platform !== undefined || t.above !== undefined
|
|
1588
|
+
? ODD
|
|
1589
|
+
: Math.floor(Math.log2(Math.max(t.cores, 1e-6)) * CLASSES)
|
|
799
1590
|
|
|
800
1591
|
/**
|
|
801
1592
|
* Ready tasks in the order `ordered` gave, by kind (`kindOf`): `assign`
|
|
@@ -806,9 +1597,8 @@ const MAX_JUMP_KINDS = 32
|
|
|
806
1597
|
* task 17 million times a plan (2026-10-25).
|
|
807
1598
|
*/
|
|
808
1599
|
class Ready {
|
|
809
|
-
private readonly
|
|
810
|
-
private readonly
|
|
811
|
-
private readonly kindAt: readonly string[]
|
|
1600
|
+
private readonly at: Kinds['at']
|
|
1601
|
+
private readonly kindAt: (place: number) => string
|
|
812
1602
|
private readonly taken: Uint8Array
|
|
813
1603
|
/** The rank: the lower `primary` first, then the lower `seq`. */
|
|
814
1604
|
private readonly primary: Float64Array
|
|
@@ -817,28 +1607,53 @@ class Ready {
|
|
|
817
1607
|
/** In rank order; taken ones linger until the next join. */
|
|
818
1608
|
private order: number[] = []
|
|
819
1609
|
private kinds = new Map<string, number[]>()
|
|
1610
|
+
/**
|
|
1611
|
+
* Ready tasks by their cores' class (`classOf`), in rank order, each
|
|
1612
|
+
* class's first not taken and the most memory one asked: what `asks`
|
|
1613
|
+
* jumps across when kinds are too many.
|
|
1614
|
+
*/
|
|
1615
|
+
private classes = new Map<number, number[]>()
|
|
1616
|
+
private readonly classHead = new Map<number, number>()
|
|
1617
|
+
/** The fewest and most cores and the most memory a task of each class asked. */
|
|
1618
|
+
private readonly classSpan = new Map<number, [number, number, number]>()
|
|
820
1619
|
/** Each kind's first task not taken. */
|
|
821
1620
|
private readonly head = new Map<string, number>()
|
|
822
1621
|
/** No task before this place in `order` is left: a pass began at 0 and read 9000 taken ones (2026-10-25). */
|
|
823
1622
|
private lo = 0
|
|
824
1623
|
/** Order it again before the next step. */
|
|
825
1624
|
stale = false
|
|
1625
|
+
/** Taken ones still in `order`: past its live ones, it is read again without them. */
|
|
1626
|
+
private dead = 0
|
|
1627
|
+
/** Each kind's tasks not taken: a kind with none leaves `kinds`. */
|
|
1628
|
+
private readonly liveOf = new Map<string, number>()
|
|
826
1629
|
|
|
827
|
-
constructor(
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
1630
|
+
constructor(
|
|
1631
|
+
private readonly tasks: readonly PlanTask[],
|
|
1632
|
+
kinds: Kinds = kindsOf(tasks),
|
|
1633
|
+
) {
|
|
1634
|
+
this.at = kinds.at
|
|
1635
|
+
this.kindAt = kinds.kindAt
|
|
831
1636
|
this.taken = new Uint8Array(tasks.length)
|
|
832
1637
|
this.primary = new Float64Array(tasks.length)
|
|
833
1638
|
this.seq = new Float64Array(tasks.length)
|
|
834
1639
|
}
|
|
835
1640
|
|
|
836
1641
|
kindOf(id: string): string {
|
|
837
|
-
return this.kindAt
|
|
1642
|
+
return this.kindAt(this.at.get(id)!)
|
|
838
1643
|
}
|
|
839
1644
|
|
|
840
1645
|
take(id: string): void {
|
|
841
|
-
this.
|
|
1646
|
+
const i = this.at.get(id)!
|
|
1647
|
+
this.taken[i] = 1
|
|
1648
|
+
this.dead++
|
|
1649
|
+
const k = this.kindAt(i)
|
|
1650
|
+
const n = this.liveOf.get(k)! - 1
|
|
1651
|
+
if (n > 0) this.liveOf.set(k, n)
|
|
1652
|
+
else {
|
|
1653
|
+
this.liveOf.delete(k)
|
|
1654
|
+
this.kinds.delete(k)
|
|
1655
|
+
this.head.delete(k)
|
|
1656
|
+
}
|
|
842
1657
|
}
|
|
843
1658
|
|
|
844
1659
|
live(): PlanTask[] {
|
|
@@ -861,6 +1676,11 @@ class Ready {
|
|
|
861
1676
|
this.order = []
|
|
862
1677
|
this.kinds = new Map()
|
|
863
1678
|
this.head.clear()
|
|
1679
|
+
this.classes = new Map()
|
|
1680
|
+
this.classHead.clear()
|
|
1681
|
+
this.classSpan.clear()
|
|
1682
|
+
this.liveOf.clear()
|
|
1683
|
+
this.dead = 0
|
|
864
1684
|
this.stale = false
|
|
865
1685
|
const batch = ids.map((id) => {
|
|
866
1686
|
const i = this.at.get(id)!
|
|
@@ -891,26 +1711,99 @@ class Ready {
|
|
|
891
1711
|
}
|
|
892
1712
|
|
|
893
1713
|
private join(batch: readonly number[]): void {
|
|
894
|
-
|
|
895
|
-
this.
|
|
1714
|
+
// Taken ones outnumber the rest: read without them once.
|
|
1715
|
+
if (this.dead > this.order.length / 2) {
|
|
1716
|
+
this.order = this.merge(this.order, [])
|
|
1717
|
+
for (const [k, q] of this.kinds) this.kinds.set(k, this.merge(q, []))
|
|
1718
|
+
this.head.clear()
|
|
1719
|
+
for (const [c, q] of this.classes) {
|
|
1720
|
+
const left = this.merge(q, [])
|
|
1721
|
+
if (left.length > 0) this.classes.set(c, left)
|
|
1722
|
+
else this.classes.delete(c)
|
|
1723
|
+
}
|
|
1724
|
+
this.classHead.clear()
|
|
1725
|
+
this.dead = 0
|
|
1726
|
+
this.lo = 0
|
|
1727
|
+
}
|
|
1728
|
+
const len = this.order.length + batch.length
|
|
1729
|
+
this.lo = Math.min(this.lo, this.insert(this.order, batch))
|
|
1730
|
+
// A merge left the taken ones out.
|
|
1731
|
+
this.dead -= len - this.order.length
|
|
896
1732
|
const by = new Map<string, number[]>()
|
|
897
1733
|
for (const i of batch) {
|
|
898
|
-
const k = this.kindAt
|
|
1734
|
+
const k = this.kindAt(i)
|
|
1735
|
+
this.liveOf.set(k, (this.liveOf.get(k) ?? 0) + 1)
|
|
899
1736
|
const q = by.get(k)
|
|
900
1737
|
if (q === undefined) by.set(k, [i])
|
|
901
1738
|
else q.push(i)
|
|
902
1739
|
}
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
const
|
|
908
|
-
|
|
909
|
-
|
|
1740
|
+
const byClass = new Map<number, number[]>()
|
|
1741
|
+
for (const i of batch) {
|
|
1742
|
+
const t = this.tasks[i]!
|
|
1743
|
+
const c = classOf(t)
|
|
1744
|
+
const span = this.classSpan.get(c)
|
|
1745
|
+
if (span === undefined) this.classSpan.set(c, [t.cores, t.cores, t.memory])
|
|
1746
|
+
else {
|
|
1747
|
+
span[0] = Math.min(span[0], t.cores)
|
|
1748
|
+
span[1] = Math.max(span[1], t.cores)
|
|
1749
|
+
span[2] = Math.max(span[2], t.memory)
|
|
1750
|
+
}
|
|
1751
|
+
const q = byClass.get(c)
|
|
1752
|
+
if (q === undefined) byClass.set(c, [i])
|
|
1753
|
+
else q.push(i)
|
|
1754
|
+
}
|
|
1755
|
+
for (const [c, q] of byClass) {
|
|
1756
|
+
const had = this.classes.get(c)
|
|
1757
|
+
if (had === undefined) {
|
|
1758
|
+
this.classes.set(c, q)
|
|
1759
|
+
this.classHead.delete(c)
|
|
1760
|
+
} else {
|
|
1761
|
+
const first = this.insert(had, q)
|
|
1762
|
+
const h = this.classHead.get(c)
|
|
1763
|
+
if (h !== undefined && first < h) this.classHead.set(c, first)
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
// Only the kinds it joins: each join read every kind and every ready
|
|
1767
|
+
// task, and a 6000-task tree spent 2 s of a 5 s plan on it (2026-11-05).
|
|
1768
|
+
for (const [k, q] of by) {
|
|
1769
|
+
const had = this.kinds.get(k)
|
|
1770
|
+
if (had === undefined) {
|
|
1771
|
+
this.kinds.set(k, q)
|
|
1772
|
+
this.head.delete(k)
|
|
1773
|
+
} else {
|
|
1774
|
+
const first = this.insert(had, q)
|
|
1775
|
+
const h = this.head.get(k)
|
|
1776
|
+
if (h !== undefined && first < h) this.head.set(k, first)
|
|
1777
|
+
}
|
|
910
1778
|
}
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
/**
|
|
1782
|
+
* `batch`, in rank order, into `into`, in rank order: merged when it is
|
|
1783
|
+
* as big, each put in its place when it is few. The first place it took.
|
|
1784
|
+
*/
|
|
1785
|
+
private insert(into: number[], batch: readonly number[]): number {
|
|
1786
|
+
if (batch.length === 0) return into.length
|
|
1787
|
+
if (batch.length * 8 > into.length) {
|
|
1788
|
+
const merged = this.merge(into, batch)
|
|
1789
|
+
into.length = 0
|
|
1790
|
+
for (const i of merged) into.push(i)
|
|
1791
|
+
return 0
|
|
1792
|
+
}
|
|
1793
|
+
let first = into.length
|
|
1794
|
+
let lo = 0
|
|
1795
|
+
for (const b of batch) {
|
|
1796
|
+
let hi = into.length
|
|
1797
|
+
while (lo < hi) {
|
|
1798
|
+
const mid = (lo + hi) >> 1
|
|
1799
|
+
if (this.before(into[mid]!, b)) lo = mid + 1
|
|
1800
|
+
else hi = mid
|
|
1801
|
+
}
|
|
1802
|
+
into.splice(lo, 0, b)
|
|
1803
|
+
first = Math.min(first, lo)
|
|
1804
|
+
lo++
|
|
1805
|
+
}
|
|
1806
|
+
return first
|
|
914
1807
|
}
|
|
915
1808
|
|
|
916
1809
|
/** In order, past each kind in `spent` as `assign` reads it then. */
|
|
@@ -918,43 +1811,116 @@ class Ready {
|
|
|
918
1811
|
// Measured needs are unrounded, so kinds can be as many as tasks: a
|
|
919
1812
|
// jump across every kind then cost more than a read of every task.
|
|
920
1813
|
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
|
-
}
|
|
1814
|
+
yield* this.byClass(spent, bounds, kinds)
|
|
931
1815
|
return
|
|
932
1816
|
}
|
|
933
|
-
|
|
1817
|
+
// Arrays, not the maps: the scan below is most of a sim's time (2026-11-05).
|
|
1818
|
+
const ks: string[] = []
|
|
1819
|
+
const qs: (readonly number[])[] = []
|
|
1820
|
+
for (const [k, q] of this.kinds) {
|
|
1821
|
+
ks.push(k)
|
|
1822
|
+
qs.push(q)
|
|
1823
|
+
}
|
|
1824
|
+
const at = new Int32Array(ks.length).fill(-1)
|
|
934
1825
|
let cursor = -1
|
|
935
1826
|
for (;;) {
|
|
936
1827
|
let next = -1
|
|
937
|
-
let
|
|
938
|
-
for (
|
|
1828
|
+
let nextAt = -1
|
|
1829
|
+
for (let x = 0; x < ks.length; x++) {
|
|
1830
|
+
const k = ks[x]!
|
|
939
1831
|
if (spent.has(k)) continue
|
|
940
|
-
|
|
1832
|
+
const q = qs[x]!
|
|
1833
|
+
let p = at[x]! === -1 ? this.headOf(k, q) : at[x]!
|
|
941
1834
|
if (p < q.length && cursor !== -1 && !this.before(cursor, q[p]!))
|
|
942
1835
|
p = this.after(q, p, cursor)
|
|
943
1836
|
while (p < q.length && this.taken[q[p]!] === 1) p++
|
|
944
|
-
at
|
|
945
|
-
if (p
|
|
1837
|
+
at[x] = p
|
|
1838
|
+
if (p === q.length) continue
|
|
1839
|
+
// A kind no bin fits or holds for is spent here, as `assign` would
|
|
1840
|
+
// spend it once read: yielded, each cost a sim of 300 tasks a
|
|
1841
|
+
// pass over the bins (2026-11-05). Bounds only shrink until a
|
|
1842
|
+
// bin is given, which clears `spent`.
|
|
1843
|
+
if (bounds !== undefined && dead(this.tasks[q[p]!]!, bounds)) {
|
|
1844
|
+
spent.add(k)
|
|
1845
|
+
continue
|
|
1846
|
+
}
|
|
1847
|
+
if (next === -1 || this.before(q[p]!, next)) {
|
|
946
1848
|
next = q[p]!
|
|
947
|
-
|
|
1849
|
+
nextAt = x
|
|
948
1850
|
}
|
|
949
1851
|
}
|
|
950
1852
|
if (next === -1) return
|
|
951
|
-
at
|
|
1853
|
+
at[nextAt]!++
|
|
952
1854
|
cursor = next
|
|
953
1855
|
const t = this.tasks[next]!
|
|
954
|
-
yield { id: t.id, need: t, kind:
|
|
1856
|
+
yield { id: t.id, need: t, kind: ks[nextAt]! }
|
|
955
1857
|
}
|
|
956
1858
|
}
|
|
957
1859
|
|
|
1860
|
+
/**
|
|
1861
|
+
* As a read of every ready task in rank order, past each taken, spent
|
|
1862
|
+
* or `dead` one, but jumping across the cores' classes: a class whose
|
|
1863
|
+
* every task no bin fits, by the span its tasks asked, is passed whole. Read one by one, the ready
|
|
1864
|
+
* tasks of a 5000-task play were most of its time (2026-11-05). A task
|
|
1865
|
+
* found dead stays dead until a bin is given, and a give only shrinks
|
|
1866
|
+
* the bins' room, so the read goes past it for the rest of the pass.
|
|
1867
|
+
*/
|
|
1868
|
+
private *byClass(spent: Set<string>, bounds?: Bounds, kinds?: number): Generator<Ask> {
|
|
1869
|
+
const cs: number[] = []
|
|
1870
|
+
const qs: (readonly number[])[] = []
|
|
1871
|
+
for (const [c, q] of this.classes) {
|
|
1872
|
+
cs.push(c)
|
|
1873
|
+
qs.push(q)
|
|
1874
|
+
}
|
|
1875
|
+
const at = new Int32Array(cs.length).fill(-1)
|
|
1876
|
+
let cursor = -1
|
|
1877
|
+
for (;;) {
|
|
1878
|
+
if (bounds?.done === true || (kinds !== undefined && spent.size >= kinds)) return
|
|
1879
|
+
let next = -1
|
|
1880
|
+
for (let x = 0; x < cs.length; x++) {
|
|
1881
|
+
const c = cs[x]!
|
|
1882
|
+
if (
|
|
1883
|
+
bounds !== undefined &&
|
|
1884
|
+
c !== ODD &&
|
|
1885
|
+
!bounds.open &&
|
|
1886
|
+
this.classSpan.get(c)![0] > bounds.free + 1e-9 &&
|
|
1887
|
+
this.classSpan.get(c)![1] < bounds.cores &&
|
|
1888
|
+
this.classSpan.get(c)![2] <= bounds.memory
|
|
1889
|
+
)
|
|
1890
|
+
continue
|
|
1891
|
+
const q = qs[x]!
|
|
1892
|
+
let p = at[x]! === -1 ? this.classHeadOf(c, q) : at[x]!
|
|
1893
|
+
if (p < q.length && cursor !== -1 && !this.before(cursor, q[p]!))
|
|
1894
|
+
p = this.after(q, p, cursor)
|
|
1895
|
+
for (; p < q.length; p++) {
|
|
1896
|
+
const i = q[p]!
|
|
1897
|
+
if (this.taken[i] === 1) continue
|
|
1898
|
+
const t = this.tasks[i]!
|
|
1899
|
+
if (bounds === undefined || !dead(t, bounds)) break
|
|
1900
|
+
spent.add(this.kindAt(i))
|
|
1901
|
+
}
|
|
1902
|
+
at[x] = p
|
|
1903
|
+
// A spent kind is passed here only: a give clears `spent`.
|
|
1904
|
+
let r = p
|
|
1905
|
+
while (r < q.length && (this.taken[q[r]!] === 1 || spent.has(this.kindAt(q[r]!)))) r++
|
|
1906
|
+
if (r < q.length && (next === -1 || this.before(q[r]!, next))) {
|
|
1907
|
+
next = q[r]!
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
if (next === -1) return
|
|
1911
|
+
cursor = next
|
|
1912
|
+
const t = this.tasks[next]!
|
|
1913
|
+
yield { id: t.id, need: t, kind: this.kindAt(next) }
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
private classHeadOf(c: number, q: readonly number[]): number {
|
|
1918
|
+
let h = this.classHead.get(c) ?? 0
|
|
1919
|
+
while (h < q.length && this.taken[q[h]!] === 1) h++
|
|
1920
|
+
this.classHead.set(c, h)
|
|
1921
|
+
return h
|
|
1922
|
+
}
|
|
1923
|
+
|
|
958
1924
|
private headOf(k: string, q: readonly number[]): number {
|
|
959
1925
|
let h = this.head.get(k) ?? 0
|
|
960
1926
|
while (h < q.length && this.taken[q[h]!] === 1) h++
|
|
@@ -986,3 +1952,187 @@ function floorOf(needs: Iterable<{ cores: number; memory: number }>): {
|
|
|
986
1952
|
}
|
|
987
1953
|
return { cores, memory }
|
|
988
1954
|
}
|
|
1955
|
+
|
|
1956
|
+
/** Workers of one shape. */
|
|
1957
|
+
export interface Fleet {
|
|
1958
|
+
readonly count: number
|
|
1959
|
+
readonly cores: number
|
|
1960
|
+
readonly memory: number
|
|
1961
|
+
readonly platform?: string | undefined
|
|
1962
|
+
/** The declared size, when it is one. */
|
|
1963
|
+
readonly size?: string | undefined
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
/** Workers that end `tasks` soonest, and when. */
|
|
1967
|
+
export interface Fleets {
|
|
1968
|
+
readonly fleet: readonly Fleet[]
|
|
1969
|
+
readonly endMs: number
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
/**
|
|
1973
|
+
* What would have run `tasks` best, played from an empty run (`advise`):
|
|
1974
|
+
* on the declared sizes, how many of each; past their `max`, how many it
|
|
1975
|
+
* would take; and of any cores, the shape that ends as soon for the
|
|
1976
|
+
* least core time.
|
|
1977
|
+
*/
|
|
1978
|
+
export interface Advice {
|
|
1979
|
+
readonly declared?: Fleets | undefined
|
|
1980
|
+
/** Only when more than the sizes' `max` end it past the margin sooner. */
|
|
1981
|
+
readonly more?: Fleets | undefined
|
|
1982
|
+
readonly any?: Fleets | undefined
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
/** The cores a shape of `Advice.any` may have. */
|
|
1986
|
+
const SHAPES = [2, 4, 8, 16, 32, 64]
|
|
1987
|
+
/** Memory a core of a shape has when nothing declares any: as GitHub's larger runners. */
|
|
1988
|
+
const MEMORY_PER_CORE = 4 * 2 ** 30
|
|
1989
|
+
|
|
1990
|
+
/**
|
|
1991
|
+
* The run's workers, judged after it: `tasks` as each took, played from
|
|
1992
|
+
* nothing on the fewest of `sizes`, in the order the live plan starts
|
|
1993
|
+
* them (`candidates`), that end within its margin of all of them; again
|
|
1994
|
+
* with each size's `max` lifted; and on one shape a platform of any
|
|
1995
|
+
* cores. Each worker joins after its platform's `warm` warm-up.
|
|
1996
|
+
*/
|
|
1997
|
+
export function advise(
|
|
1998
|
+
tasks: readonly PlanTask[],
|
|
1999
|
+
sizes: readonly Size[],
|
|
2000
|
+
warm: (platform: string | undefined) => number,
|
|
2001
|
+
how: Strategy = DEFAULT_STRATEGY,
|
|
2002
|
+
): Advice {
|
|
2003
|
+
if (tasks.length === 0) return {}
|
|
2004
|
+
const perPlatform = new Map<string | undefined, number>()
|
|
2005
|
+
for (const t of tasks) perPlatform.set(t.platform, (perPlatform.get(t.platform) ?? 0) + 1)
|
|
2006
|
+
const wants = new Map(
|
|
2007
|
+
[...perPlatform].flatMap(([p, n]) => (p === undefined ? [] : [[p, n] as const])),
|
|
2008
|
+
)
|
|
2009
|
+
const atOf = (s: Size): number => warm(s.platform)
|
|
2010
|
+
const fleetOf = (kept: readonly PlanWorker[], of: readonly Size[]): Fleet[] => {
|
|
2011
|
+
const by = new Map<string, number>()
|
|
2012
|
+
for (const k of kept) if (k.size !== undefined) by.set(k.size, (by.get(k.size) ?? 0) + 1)
|
|
2013
|
+
return of.flatMap((s) => {
|
|
2014
|
+
const count = by.get(s.name) ?? 0
|
|
2015
|
+
return count === 0
|
|
2016
|
+
? []
|
|
2017
|
+
: [{ count, cores: s.cpu, memory: s.memory, platform: s.platform, size: s.name }]
|
|
2018
|
+
})
|
|
2019
|
+
}
|
|
2020
|
+
const byName = (w: readonly Size[]) => (size: string | undefined) =>
|
|
2021
|
+
atOf(w.find((s) => s.name === size)!)
|
|
2022
|
+
// More workers than run at once end it no sooner: of `cores` on a
|
|
2023
|
+
// platform, at most twice its work over the longest chain, a task each
|
|
2024
|
+
// at most. Without the cap 1000 tasks played 1000 workers (70 s).
|
|
2025
|
+
const chain = longestMs(tasks)
|
|
2026
|
+
const work = new Map<string | undefined, number>()
|
|
2027
|
+
for (const t of tasks)
|
|
2028
|
+
work.set(t.platform, (work.get(t.platform) ?? 0) + t.cores * (t.ms ?? UNKNOWN_MS))
|
|
2029
|
+
const capOf = (cores: number, platform: string | undefined): number => {
|
|
2030
|
+
let w = 0
|
|
2031
|
+
let n = 0
|
|
2032
|
+
for (const [p, c] of perPlatform)
|
|
2033
|
+
if (platform === undefined || p === platform) {
|
|
2034
|
+
w += work.get(p) ?? 0
|
|
2035
|
+
n += c
|
|
2036
|
+
}
|
|
2037
|
+
return Math.min(n, 2 * Math.ceil(w / cores / Math.max(1, chain)) + 1)
|
|
2038
|
+
}
|
|
2039
|
+
/** The fewest of `of`, each up to its `max`, ending within the margin of all of them: halving. */
|
|
2040
|
+
const halved = (of: readonly Size[]): { fleets: Fleets; kept: PlanWorker[] } | undefined => {
|
|
2041
|
+
const at = byName(of)
|
|
2042
|
+
const all = candidates(of, new Map(), [], Infinity, wants).map((s, i) =>
|
|
2043
|
+
fresh({ ...s, size: s.name }, `\0advice${i}`, at(s.name)),
|
|
2044
|
+
)
|
|
2045
|
+
const best = simulate(tasks, all, how)
|
|
2046
|
+
if (!Number.isFinite(best)) return undefined
|
|
2047
|
+
const bound = best + Math.max(MARGIN_MS, best * MARGIN)
|
|
2048
|
+
let lo = 1
|
|
2049
|
+
let hi = all.length
|
|
2050
|
+
while (lo < hi) {
|
|
2051
|
+
const mid = (lo + hi) >> 1
|
|
2052
|
+
if (simulate(tasks, all.slice(0, mid), how) <= bound) hi = mid
|
|
2053
|
+
else lo = mid + 1
|
|
2054
|
+
}
|
|
2055
|
+
const kept = all.slice(0, lo)
|
|
2056
|
+
return { fleets: { fleet: fleetOf(kept, of), endMs: simulate(tasks, kept, how) }, kept }
|
|
2057
|
+
}
|
|
2058
|
+
const declared = sizes.length === 0 ? undefined : halved(sizes)?.fleets
|
|
2059
|
+
const lifted = sizes.map((s) => ({ ...s, max: Math.max(s.max, capOf(s.cpu, s.platform)) }))
|
|
2060
|
+
const wider = lifted.some((s, i) => s.max > sizes[i]!.max) ? halved(lifted)?.fleets : undefined
|
|
2061
|
+
const more =
|
|
2062
|
+
wider !== undefined &&
|
|
2063
|
+
declared !== undefined &&
|
|
2064
|
+
wider.endMs + Math.max(MARGIN_MS, declared.endMs * MARGIN) < declared.endMs
|
|
2065
|
+
? wider
|
|
2066
|
+
: undefined
|
|
2067
|
+
// Of any cores: each platform's memory a core as its sizes have most.
|
|
2068
|
+
const ratio = new Map<string | undefined, number>()
|
|
2069
|
+
for (const s of sizes)
|
|
2070
|
+
ratio.set(s.platform, Math.max(ratio.get(s.platform) ?? 0, s.memory / s.cpu))
|
|
2071
|
+
const most = new Map<string | undefined, number>()
|
|
2072
|
+
for (const t of tasks) most.set(t.platform, Math.max(most.get(t.platform) ?? 0, t.memory))
|
|
2073
|
+
const tried: { fleets: Fleets; coreMs: number; workers: number }[] = []
|
|
2074
|
+
for (const cores of SHAPES) {
|
|
2075
|
+
const shapes: Size[] = [...perPlatform.keys()].map((platform) => ({
|
|
2076
|
+
name: `${cores}#${platform ?? ''}`,
|
|
2077
|
+
max: capOf(cores, platform),
|
|
2078
|
+
cpu: cores,
|
|
2079
|
+
memory: Math.max(
|
|
2080
|
+
cores * (ratio.get(platform) ?? ratio.get(undefined) ?? MEMORY_PER_CORE),
|
|
2081
|
+
most.get(platform) ?? 0,
|
|
2082
|
+
),
|
|
2083
|
+
platform,
|
|
2084
|
+
}))
|
|
2085
|
+
const got = halved(shapes)
|
|
2086
|
+
if (got === undefined) continue
|
|
2087
|
+
let coreMs = 0
|
|
2088
|
+
for (const k of got.kept) coreMs += k.cores * Math.max(0, got.fleets.endMs - k.at)
|
|
2089
|
+
tried.push({ fleets: got.fleets, coreMs, workers: got.kept.length })
|
|
2090
|
+
}
|
|
2091
|
+
let any: Fleets | undefined
|
|
2092
|
+
if (tried.length > 0) {
|
|
2093
|
+
const soonest = Math.min(...tried.map((t) => t.fleets.endMs))
|
|
2094
|
+
const bound = soonest + Math.max(MARGIN_MS, soonest * MARGIN)
|
|
2095
|
+
let cheapest: (typeof tried)[number] | undefined
|
|
2096
|
+
for (const t of tried)
|
|
2097
|
+
if (
|
|
2098
|
+
t.fleets.endMs <= bound &&
|
|
2099
|
+
(cheapest === undefined ||
|
|
2100
|
+
t.coreMs < cheapest.coreMs ||
|
|
2101
|
+
// As cheap, fewer machines.
|
|
2102
|
+
(t.coreMs === cheapest.coreMs && t.workers < cheapest.workers))
|
|
2103
|
+
)
|
|
2104
|
+
cheapest = t
|
|
2105
|
+
any = {
|
|
2106
|
+
fleet: cheapest!.fleets.fleet.map((f) => ({
|
|
2107
|
+
count: f.count,
|
|
2108
|
+
cores: f.cores,
|
|
2109
|
+
memory: f.memory,
|
|
2110
|
+
platform: f.platform,
|
|
2111
|
+
})),
|
|
2112
|
+
endMs: cheapest!.fleets.endMs,
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
2115
|
+
return { declared, more, any }
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
/** The longest chain of `tasks` by their `ms`, through the deps among them. */
|
|
2119
|
+
function longestMs(tasks: readonly PlanTask[]): number {
|
|
2120
|
+
const byId = new Map(tasks.map((t) => [t.id, t]))
|
|
2121
|
+
const memo = new Map<string, number>()
|
|
2122
|
+
const of = (t: PlanTask): number => {
|
|
2123
|
+
const had = memo.get(t.id)
|
|
2124
|
+
if (had !== undefined) return had
|
|
2125
|
+
memo.set(t.id, 0)
|
|
2126
|
+
let up = 0
|
|
2127
|
+
for (const d of t.deps) {
|
|
2128
|
+
const u = byId.get(d)
|
|
2129
|
+
if (u !== undefined) up = Math.max(up, of(u))
|
|
2130
|
+
}
|
|
2131
|
+
const out = up + (t.ms ?? UNKNOWN_MS)
|
|
2132
|
+
memo.set(t.id, out)
|
|
2133
|
+
return out
|
|
2134
|
+
}
|
|
2135
|
+
let most = 0
|
|
2136
|
+
for (const t of tasks) most = Math.max(most, of(t))
|
|
2137
|
+
return most
|
|
2138
|
+
}
|