nx 23.1.0-beta.3 → 23.1.0-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/command-line/migrate/migrate.d.ts +1 -0
- package/dist/src/command-line/migrate/migrate.js +25 -4
- package/dist/src/daemon/server/project-graph-incremental-recomputation.js +30 -11
- package/dist/src/native/index.d.ts +48 -1
- package/dist/src/native/native-bindings.js +1 -0
- package/dist/src/native/nx.wasm32-wasi.debug.wasm +0 -0
- package/dist/src/native/nx.wasm32-wasi.wasm +0 -0
- package/dist/src/tasks-runner/default-tasks-runner.js +6 -3
- package/dist/src/tasks-runner/init-tasks-runner.js +1 -1
- package/dist/src/tasks-runner/life-cycle.d.ts +10 -5
- package/dist/src/tasks-runner/life-cycle.js +4 -4
- package/dist/src/tasks-runner/life-cycles/performance-analysis.d.ts +156 -0
- package/dist/src/tasks-runner/life-cycles/performance-analysis.js +486 -0
- package/dist/src/tasks-runner/life-cycles/performance-life-cycle.d.ts +59 -0
- package/dist/src/tasks-runner/life-cycles/performance-life-cycle.js +152 -0
- package/dist/src/tasks-runner/life-cycles/performance-report.d.ts +62 -0
- package/dist/src/tasks-runner/life-cycles/performance-report.js +318 -0
- package/dist/src/tasks-runner/run-command.d.ts +1 -1
- package/dist/src/tasks-runner/run-command.js +12 -2
- package/dist/src/utils/min-release-age/behavior/pnpm.d.ts +6 -5
- package/dist/src/utils/min-release-age/behavior/pnpm.js +87 -339
- package/dist/src/utils/package-manager.d.ts +6 -3
- package/dist/src/utils/package-manager.js +11 -7
- package/dist/src/utils/terminal-link.d.ts +10 -6
- package/dist/src/utils/terminal-link.js +9 -11
- package/package.json +11 -11
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.PerformanceAnalysis = void 0;
|
|
4
|
+
exports.mergeIntervals = mergeIntervals;
|
|
5
|
+
exports.overlap = overlap;
|
|
6
|
+
exports.preDispatchHashTime = preDispatchHashTime;
|
|
7
|
+
exports.buildTimespans = buildTimespans;
|
|
8
|
+
const tslib_1 = require("tslib");
|
|
9
|
+
const os = tslib_1.__importStar(require("node:os"));
|
|
10
|
+
const node_perf_hooks_1 = require("node:perf_hooks");
|
|
11
|
+
const nx_cloud_utils_1 = require("../../utils/nx-cloud-utils");
|
|
12
|
+
const is_ci_1 = require("../../utils/is-ci");
|
|
13
|
+
const performance_report_1 = require("./performance-report");
|
|
14
|
+
const CACHE_HIT_STATUSES = new Set([
|
|
15
|
+
'local-cache',
|
|
16
|
+
'local-cache-kept-existing',
|
|
17
|
+
'remote-cache',
|
|
18
|
+
]);
|
|
19
|
+
/** Gap (ms) still counted as the same pre-dispatch hashing phase; a larger gap means older, unrelated hashing (e.g. a daemon's previous run). */
|
|
20
|
+
const PRE_DISPATCH_HASH_GAP = 1000;
|
|
21
|
+
/**
|
|
22
|
+
* Pure analysis over one finished run's collected timings — no lifecycle state and no
|
|
23
|
+
* mutation. Built once per run from the {@link PerformanceLifeCycle}'s accumulated
|
|
24
|
+
* maps; everything here is a function of that snapshot plus the environment it reads.
|
|
25
|
+
*/
|
|
26
|
+
class PerformanceAnalysis {
|
|
27
|
+
constructor(timings, statuses, taskGraph, batchSiblings,
|
|
28
|
+
/** Resolved `--parallel` (getThreadPoolSize's `discrete`), set on the lifecycle by the runner; falls back to 1. */
|
|
29
|
+
parallel, options) {
|
|
30
|
+
this.timings = timings;
|
|
31
|
+
this.statuses = statuses;
|
|
32
|
+
this.taskGraph = taskGraph;
|
|
33
|
+
this.batchSiblings = batchSiblings;
|
|
34
|
+
this.parallel = parallel;
|
|
35
|
+
this.options = options;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Whether a remote (Nx Cloud) cache is active, from the nx.json handed in at
|
|
39
|
+
* construction. Assumes no remote cache when it wasn't provided rather than
|
|
40
|
+
* re-reading nx.json from disk — the CLI path always provides it.
|
|
41
|
+
*/
|
|
42
|
+
remoteCacheEnabled() {
|
|
43
|
+
return this.options.nxJson ? (0, nx_cloud_utils_1.isNxCloudUsed)(this.options.nxJson) : false;
|
|
44
|
+
}
|
|
45
|
+
// === Analysis (pure functions of the collected timings + task graph) ===
|
|
46
|
+
/** Discrete tasks that ran and have a start + end timestamp. */
|
|
47
|
+
timedTasks() {
|
|
48
|
+
const result = [];
|
|
49
|
+
for (const [id, t] of this.timings) {
|
|
50
|
+
if (!t.continuous && t.startTime != null && t.endTime != null) {
|
|
51
|
+
result.push({
|
|
52
|
+
id,
|
|
53
|
+
start: t.startTime,
|
|
54
|
+
end: t.endTime,
|
|
55
|
+
// A parallelism:false task occupies the whole pool; carried here so the
|
|
56
|
+
// occupancy timeline (buildTimespans) stays a pure function of `timed`.
|
|
57
|
+
nonParallel: this.taskGraph.tasks[id]?.parallelism === false,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Longest-duration chain through the dependency DAG — the floor with unlimited
|
|
65
|
+
* slots. `finishEstimate(t) = duration[t] + max(finishEstimate(p) for predecessors
|
|
66
|
+
* p)`. Predecessors are real dependencies *plus* earlier batch siblings (a batch runs
|
|
67
|
+
* sequentially in one process, so that ordering is part of the floor too). On equal
|
|
68
|
+
* finish estimates keep the longer-running predecessor.
|
|
69
|
+
*/
|
|
70
|
+
criticalPath(durations) {
|
|
71
|
+
const ctx = {
|
|
72
|
+
durations,
|
|
73
|
+
memo: new Map(),
|
|
74
|
+
pred: new Map(),
|
|
75
|
+
visiting: new Set(),
|
|
76
|
+
};
|
|
77
|
+
let terminal = null;
|
|
78
|
+
let terminalFinish = -1;
|
|
79
|
+
let terminalDur = -1;
|
|
80
|
+
for (const id of durations.keys()) {
|
|
81
|
+
const f = this.finishEstimate(id, ctx);
|
|
82
|
+
const d = durations.get(id) ?? 0;
|
|
83
|
+
if (finishesLater(f, d, terminalFinish, terminalDur)) {
|
|
84
|
+
terminalFinish = f;
|
|
85
|
+
terminalDur = d;
|
|
86
|
+
terminal = id;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
path: tracePath(terminal, ctx.pred),
|
|
91
|
+
duration: terminal == null ? 0 : terminalFinish,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Real predecessors of `id`: dependencies plus any earlier batch sibling that FINISHED
|
|
96
|
+
* before `id` started. A batch runs sequentially in one process, so that ordering is
|
|
97
|
+
* part of the floor; concurrent batch executors aren't, and chaining them would inflate
|
|
98
|
+
* it. (Matches readyTime.)
|
|
99
|
+
*/
|
|
100
|
+
predecessorsFor(id, durations) {
|
|
101
|
+
const start = this.timings.get(id)?.startTime ?? Infinity;
|
|
102
|
+
const deps = (this.taskGraph.dependencies[id] ?? []).filter((d) => durations.has(d));
|
|
103
|
+
const siblings = (this.batchSiblings.get(id) ?? []).filter((s) => {
|
|
104
|
+
const sEnd = this.timings.get(s)?.endTime;
|
|
105
|
+
return durations.has(s) && sEnd != null && sEnd <= start;
|
|
106
|
+
});
|
|
107
|
+
return [...deps, ...siblings];
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Longest finish time of any chain reaching `id` — its duration plus the best
|
|
111
|
+
* predecessor's finish estimate — memoized in `ctx`. `ctx.visiting` guards a cycle in
|
|
112
|
+
* a malformed graph (the back-edge contributes nothing).
|
|
113
|
+
*/
|
|
114
|
+
finishEstimate(id, ctx) {
|
|
115
|
+
const cached = ctx.memo.get(id);
|
|
116
|
+
if (cached != null) {
|
|
117
|
+
return cached;
|
|
118
|
+
}
|
|
119
|
+
if (ctx.visiting.has(id)) {
|
|
120
|
+
return 0;
|
|
121
|
+
}
|
|
122
|
+
ctx.visiting.add(id);
|
|
123
|
+
let best = 0;
|
|
124
|
+
let bestPred = null;
|
|
125
|
+
let bestDur = -1;
|
|
126
|
+
for (const p of this.predecessorsFor(id, ctx.durations)) {
|
|
127
|
+
const f = this.finishEstimate(p, ctx);
|
|
128
|
+
const d = ctx.durations.get(p) ?? 0;
|
|
129
|
+
if (finishesLater(f, d, best, bestDur)) {
|
|
130
|
+
best = f;
|
|
131
|
+
bestPred = p;
|
|
132
|
+
bestDur = d;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
ctx.visiting.delete(id);
|
|
136
|
+
const value = best + (ctx.durations.get(id) ?? 0);
|
|
137
|
+
ctx.memo.set(id, value);
|
|
138
|
+
ctx.pred.set(id, bestPred);
|
|
139
|
+
return value;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Earliest this task became eligible, independent of slots: the latest of run
|
|
143
|
+
* start, dependency ends, any continuous dependency's *start* (an ordering
|
|
144
|
+
* constraint, not contention), and any earlier batch sibling's end.
|
|
145
|
+
*/
|
|
146
|
+
readyTime(id, runStart) {
|
|
147
|
+
const start = this.timings.get(id)?.startTime;
|
|
148
|
+
let result = runStart;
|
|
149
|
+
for (const dep of this.taskGraph.dependencies[id] ?? []) {
|
|
150
|
+
const end = this.timings.get(dep)?.endTime;
|
|
151
|
+
if (end != null) {
|
|
152
|
+
result = Math.max(result, end);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
for (const cdep of this.taskGraph.continuousDependencies?.[id] ?? []) {
|
|
156
|
+
const cStart = this.timings.get(cdep)?.startTime;
|
|
157
|
+
if (cStart != null) {
|
|
158
|
+
result = Math.max(result, cStart);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
for (const sibling of this.batchSiblings.get(id) ?? []) {
|
|
162
|
+
const end = this.timings.get(sibling)?.endTime;
|
|
163
|
+
if (end != null && start != null && end <= start) {
|
|
164
|
+
result = Math.max(result, end);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return result;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Longest critical-path tasks that RAN (desc, capped at 3). Cache hits are excluded
|
|
171
|
+
* (their duration is just restore time); no-status tasks (synthetic test runs) are
|
|
172
|
+
* kept.
|
|
173
|
+
*/
|
|
174
|
+
computeCriticalPathTop(criticalPathTasks, durations) {
|
|
175
|
+
return criticalPathTasks
|
|
176
|
+
.filter((id) => {
|
|
177
|
+
const status = this.statuses.get(id);
|
|
178
|
+
return !status || !CACHE_HIT_STATUSES.has(status);
|
|
179
|
+
})
|
|
180
|
+
.map((id) => ({ id, duration: durations.get(id) ?? 0 }))
|
|
181
|
+
.sort((a, b) => b.duration - a.duration)
|
|
182
|
+
.slice(0, 3);
|
|
183
|
+
}
|
|
184
|
+
/** Cache outcome: tasks restored (`cacheHits`) and the total with a cache outcome (`cacheableCount` = hits + ran). No-status tasks count for neither. */
|
|
185
|
+
computeCacheStats() {
|
|
186
|
+
let cacheHits = 0;
|
|
187
|
+
let cacheRan = 0;
|
|
188
|
+
for (const status of this.statuses.values()) {
|
|
189
|
+
if (CACHE_HIT_STATUSES.has(status)) {
|
|
190
|
+
cacheHits++;
|
|
191
|
+
}
|
|
192
|
+
else if (status === 'success') {
|
|
193
|
+
cacheRan++;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return { cacheHits, cacheableCount: cacheHits + cacheRan };
|
|
197
|
+
}
|
|
198
|
+
/** The structured performance summary, or `null` when no discrete task timings were recorded. */
|
|
199
|
+
summary() {
|
|
200
|
+
const timed = this.timedTasks();
|
|
201
|
+
if (timed.length === 0) {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
const { durations, totalWork, runStart, taskWindow } = computeRunWindow(timed);
|
|
205
|
+
const eligible = new Map();
|
|
206
|
+
for (const { id } of timed) {
|
|
207
|
+
eligible.set(id, this.readyTime(id, runStart));
|
|
208
|
+
}
|
|
209
|
+
const { path: criticalPathTasks, duration: criticalPathDuration } = this.criticalPath(durations);
|
|
210
|
+
const parallel = Math.max(1, this.parallel);
|
|
211
|
+
const cores = detectCoreCount();
|
|
212
|
+
const timespans = buildTimespans(timed, eligible, parallel);
|
|
213
|
+
const hashWindows = collectHashWindows();
|
|
214
|
+
// Pre-dispatch hashing is part of overhead but not the slot split.
|
|
215
|
+
const runDuration = taskWindow + preDispatchHashTime(runStart, hashWindows);
|
|
216
|
+
const overhead = Math.max(0, runDuration - criticalPathDuration);
|
|
217
|
+
const { coordinatorOverhead, recoverableByMachines, recoverableByParallel, } = splitOverhead({
|
|
218
|
+
timespans,
|
|
219
|
+
parallel,
|
|
220
|
+
overhead,
|
|
221
|
+
totalWork,
|
|
222
|
+
cores,
|
|
223
|
+
criticalPathDuration,
|
|
224
|
+
});
|
|
225
|
+
const criticalPathTop = this.computeCriticalPathTop(criticalPathTasks, durations);
|
|
226
|
+
const { cacheHits, cacheableCount } = this.computeCacheStats();
|
|
227
|
+
// `skipNxCache` already folds in NX_SKIP_NX_CACHE / NX_DISABLE_NX_CACHE
|
|
228
|
+
// (normalized in command-line-utils) — don't re-read those env vars here.
|
|
229
|
+
const cacheSkipped = this.options.skipNxCache === true;
|
|
230
|
+
const remoteCacheEnabled = this.remoteCacheEnabled();
|
|
231
|
+
// Coordinator-dominated: hashing/scheduling outweighs task work by >3x the
|
|
232
|
+
// critical path, which keeps cold runs critical-path-bound.
|
|
233
|
+
const coordinatorDominated = coordinatorOverhead >= performance_report_1.MEANINGFUL_OVERHEAD &&
|
|
234
|
+
coordinatorOverhead > 3 * criticalPathDuration;
|
|
235
|
+
// isCiEnv() checks the full set of CI env vars, not just `CI`.
|
|
236
|
+
const isCI = !!(0, is_ci_1.isCI)();
|
|
237
|
+
// TODO: source from the light client's isDistributedExecution() rather than the env var.
|
|
238
|
+
const distributing = !!process.env.NX_CLOUD_DISTRIBUTED_EXECUTION_AGENT_COUNT;
|
|
239
|
+
const structuredRecommendations = (0, performance_report_1.buildRecommendation)({
|
|
240
|
+
recoverableByParallel,
|
|
241
|
+
recoverableByMachines,
|
|
242
|
+
coordinatorDominated,
|
|
243
|
+
runDuration,
|
|
244
|
+
parallel,
|
|
245
|
+
cores,
|
|
246
|
+
// Can only start distributing in CI when not already doing so.
|
|
247
|
+
canDistribute: isCI && !distributing,
|
|
248
|
+
distributing,
|
|
249
|
+
criticalPathTop,
|
|
250
|
+
});
|
|
251
|
+
return {
|
|
252
|
+
runDuration,
|
|
253
|
+
criticalPathDuration,
|
|
254
|
+
criticalPathTaskCount: criticalPathTasks.length,
|
|
255
|
+
criticalPathTop,
|
|
256
|
+
overhead,
|
|
257
|
+
recoverableByParallel,
|
|
258
|
+
recoverableByMachines,
|
|
259
|
+
coordinatorOverhead,
|
|
260
|
+
parallel,
|
|
261
|
+
cores,
|
|
262
|
+
isCI,
|
|
263
|
+
structuredRecommendations,
|
|
264
|
+
coordinatorDominated,
|
|
265
|
+
cacheHits,
|
|
266
|
+
cacheableCount,
|
|
267
|
+
cacheSkipped,
|
|
268
|
+
remoteCacheEnabled,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
exports.PerformanceAnalysis = PerformanceAnalysis;
|
|
273
|
+
// === Analysis helpers (module-level; no instance state) ===
|
|
274
|
+
/**
|
|
275
|
+
* True when a `(finishEstimate, duration)` candidate finishes later than the current
|
|
276
|
+
* best — higher finish wins, ties broken by the longer-running task.
|
|
277
|
+
*/
|
|
278
|
+
function finishesLater(finish, dur, bestFinish, bestDur) {
|
|
279
|
+
return finish > bestFinish || (finish === bestFinish && dur > bestDur);
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Follow the recorded best-predecessor links from `end` back to the root, returning
|
|
283
|
+
* the path root → end. `seen` guards against a cycle in a malformed `pred` chain.
|
|
284
|
+
*/
|
|
285
|
+
function tracePath(end, pred) {
|
|
286
|
+
const path = [];
|
|
287
|
+
const seen = new Set();
|
|
288
|
+
for (let node = end; node != null && !seen.has(node); node = pred.get(node) ?? null) {
|
|
289
|
+
seen.add(node);
|
|
290
|
+
path.unshift(node);
|
|
291
|
+
}
|
|
292
|
+
return path;
|
|
293
|
+
}
|
|
294
|
+
/** Absolute-epoch [start, end] hashing windows from nx's existing `hash*` perf measures; [] if the performance API is unavailable. */
|
|
295
|
+
function collectHashWindows() {
|
|
296
|
+
try {
|
|
297
|
+
const origin = node_perf_hooks_1.performance.timeOrigin;
|
|
298
|
+
return node_perf_hooks_1.performance
|
|
299
|
+
.getEntriesByType('measure')
|
|
300
|
+
.filter((m) => m.name.startsWith('hash'))
|
|
301
|
+
.map((m) => [
|
|
302
|
+
origin + m.startTime,
|
|
303
|
+
origin + m.startTime + m.duration,
|
|
304
|
+
]);
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
return [];
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Union a set of `[start, end]` intervals into an ordered, non-overlapping set, e.g.
|
|
312
|
+
* `[[1, 3], [2, 5]]` → `[[1, 5]]`. Sort by start, then sweep once: each interval
|
|
313
|
+
* either extends the previous merged one (if it overlaps or touches it) or begins a
|
|
314
|
+
* new one.
|
|
315
|
+
*/
|
|
316
|
+
function mergeIntervals(intervals) {
|
|
317
|
+
const byStart = [...intervals].sort((a, b) => a[0] - b[0]);
|
|
318
|
+
const merged = [];
|
|
319
|
+
for (const [start, end] of byStart) {
|
|
320
|
+
const previous = merged[merged.length - 1];
|
|
321
|
+
// `previous[1]` is the running end of the last merged interval. This interval
|
|
322
|
+
// overlaps or touches it when its start is at or before that end — so absorb it
|
|
323
|
+
// by extending the end. Otherwise it's disjoint and starts a new interval.
|
|
324
|
+
const overlapsPrevious = previous != null && start <= previous[1];
|
|
325
|
+
if (overlapsPrevious) {
|
|
326
|
+
previous[1] = Math.max(previous[1], end);
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
merged.push([start, end]);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return merged;
|
|
333
|
+
}
|
|
334
|
+
/** Total time [a, b] overlaps the given (unsorted, possibly overlapping) intervals. */
|
|
335
|
+
function overlap(a, b, intervals) {
|
|
336
|
+
let sum = 0;
|
|
337
|
+
for (const [s, e] of intervals) {
|
|
338
|
+
sum += Math.max(0, Math.min(b, e) - Math.max(a, s));
|
|
339
|
+
}
|
|
340
|
+
return sum;
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Coordinator hashing wall-clock that ran *before* the first task started — the run
|
|
344
|
+
* window (first task start → last task end) would otherwise miss it. Matters on a
|
|
345
|
+
* cached run where hashing dominates but the tasks restore in milliseconds.
|
|
346
|
+
*
|
|
347
|
+
* `hashWindows` are absolute `[start, end]` spans from nx's `hash*` perf measures.
|
|
348
|
+
*/
|
|
349
|
+
function preDispatchHashTime(firstTaskStart, hashWindows) {
|
|
350
|
+
// Keep only the part of each window before the first task, drop any that become
|
|
351
|
+
// empty, then union overlaps so shared time isn't counted twice.
|
|
352
|
+
const preDispatchWindows = mergeIntervals(hashWindows
|
|
353
|
+
.map(([start, end]) => [
|
|
354
|
+
start,
|
|
355
|
+
Math.min(end, firstTaskStart),
|
|
356
|
+
])
|
|
357
|
+
.filter(([start, end]) => end > start));
|
|
358
|
+
// Walk newest → oldest, adding each window while it stays contiguous with the one
|
|
359
|
+
// after it. Stop at the first gap wider than PRE_DISPATCH_HASH_GAP — anything older
|
|
360
|
+
// is stale hashing from an earlier run in this process (e.g. the daemon's).
|
|
361
|
+
let total = 0;
|
|
362
|
+
let contiguousFrom = firstTaskStart;
|
|
363
|
+
for (let i = preDispatchWindows.length - 1; i >= 0; i--) {
|
|
364
|
+
const [start, end] = preDispatchWindows[i];
|
|
365
|
+
const gapTooLarge = end < contiguousFrom - PRE_DISPATCH_HASH_GAP;
|
|
366
|
+
if (gapTooLarge) {
|
|
367
|
+
break;
|
|
368
|
+
}
|
|
369
|
+
total += end - start;
|
|
370
|
+
contiguousFrom = start;
|
|
371
|
+
}
|
|
372
|
+
return total;
|
|
373
|
+
}
|
|
374
|
+
/** Per-task durations, total work, and the run window from the timed tasks. */
|
|
375
|
+
function computeRunWindow(timed) {
|
|
376
|
+
const durations = new Map();
|
|
377
|
+
let totalWork = 0;
|
|
378
|
+
let runStart = Infinity;
|
|
379
|
+
let runEnd = -Infinity;
|
|
380
|
+
for (const { id, start, end } of timed) {
|
|
381
|
+
const duration = Math.max(0, end - start);
|
|
382
|
+
durations.set(id, duration);
|
|
383
|
+
totalWork += duration;
|
|
384
|
+
runStart = Math.min(runStart, start);
|
|
385
|
+
runEnd = Math.max(runEnd, end);
|
|
386
|
+
}
|
|
387
|
+
return {
|
|
388
|
+
durations,
|
|
389
|
+
totalWork,
|
|
390
|
+
runStart,
|
|
391
|
+
taskWindow: Math.max(0, runEnd - runStart),
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
/** cgroup-aware core count so a quota-capped CI container gets the right lever (more machines, not a --parallel it can't use). cpus() only as fallback. */
|
|
395
|
+
function detectCoreCount() {
|
|
396
|
+
return typeof os.availableParallelism === 'function'
|
|
397
|
+
? os.availableParallelism()
|
|
398
|
+
: os.cpus().length;
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* The occupancy timeline (contiguous {@link Timespan}s) — single source of truth for
|
|
402
|
+
* slot contention. A `parallelism: false` task occupies the whole pool. Integrates
|
|
403
|
+
* over time rather than sampling one instant, so it's robust to when tasks hand off.
|
|
404
|
+
*
|
|
405
|
+
* Builds it via a delta sweep: each task bumps a counter up at its start and down at
|
|
406
|
+
* its end, then we walk the sorted boundaries accumulating the running totals into one
|
|
407
|
+
* timespan per gap.
|
|
408
|
+
*/
|
|
409
|
+
function buildTimespans(timed, eligible, parallel) {
|
|
410
|
+
// Per-timestamp +/- deltas the sweep integrates into timespans: busy slots (occ),
|
|
411
|
+
// eligible-but-waiting tasks, and parallelism:false tasks that hold the whole pool.
|
|
412
|
+
const occDelta = new Map();
|
|
413
|
+
const waitDelta = new Map();
|
|
414
|
+
const nonParallelDelta = new Map();
|
|
415
|
+
const addDelta = (deltas, at, delta) => deltas.set(at, (deltas.get(at) ?? 0) + delta);
|
|
416
|
+
for (const { id, start, end, nonParallel: isNonParallel } of timed) {
|
|
417
|
+
// A parallelism:false task holds every slot, so it weighs the whole pool.
|
|
418
|
+
const weight = isNonParallel ? parallel : 1;
|
|
419
|
+
addDelta(occDelta, start, weight);
|
|
420
|
+
addDelta(occDelta, end, -weight);
|
|
421
|
+
if (isNonParallel) {
|
|
422
|
+
addDelta(nonParallelDelta, start, 1);
|
|
423
|
+
addDelta(nonParallelDelta, end, -1);
|
|
424
|
+
}
|
|
425
|
+
const elig = eligible.get(id) ?? start;
|
|
426
|
+
if (elig < start) {
|
|
427
|
+
addDelta(waitDelta, elig, 1);
|
|
428
|
+
addDelta(waitDelta, start, -1);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
const times = Array.from(new Set([
|
|
432
|
+
...occDelta.keys(),
|
|
433
|
+
...waitDelta.keys(),
|
|
434
|
+
...nonParallelDelta.keys(),
|
|
435
|
+
])).sort((a, b) => a - b);
|
|
436
|
+
const timespans = [];
|
|
437
|
+
let occ = 0;
|
|
438
|
+
let waiting = 0;
|
|
439
|
+
let nonParallel = 0;
|
|
440
|
+
for (let i = 0; i < times.length; i++) {
|
|
441
|
+
occ += occDelta.get(times[i]) ?? 0;
|
|
442
|
+
waiting += waitDelta.get(times[i]) ?? 0;
|
|
443
|
+
nonParallel += nonParallelDelta.get(times[i]) ?? 0;
|
|
444
|
+
const next = times[i + 1];
|
|
445
|
+
if (next != null && next > times[i]) {
|
|
446
|
+
timespans.push({ start: times[i], end: next, occ, waiting, nonParallel });
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return timespans;
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Split overhead off the occupancy timeline into three buckets that sum to overhead:
|
|
453
|
+
* coordinator time, and slot contention recoverable by more machines vs by a higher
|
|
454
|
+
* --parallel.
|
|
455
|
+
*
|
|
456
|
+
* Slot contention is wall-clock where every slot was busy with a backlog (occ ≥
|
|
457
|
+
* parallel, waiting > 0). Contention while a `parallelism: false` task held the whole
|
|
458
|
+
* pool goes to machines only (a higher local --parallel can't recover it). Of the
|
|
459
|
+
* rest, --parallel helps up to the volume the cores can absorb; the overflow
|
|
460
|
+
* (totalWork/cores − floor) needs more machines.
|
|
461
|
+
*/
|
|
462
|
+
function splitOverhead({ timespans, parallel, overhead, totalWork, cores, criticalPathDuration, }) {
|
|
463
|
+
let slotContendedTime = 0;
|
|
464
|
+
let nonParallelContendedTime = 0;
|
|
465
|
+
for (const s of timespans) {
|
|
466
|
+
if (s.occ >= parallel && s.waiting > 0) {
|
|
467
|
+
const dur = s.end - s.start;
|
|
468
|
+
slotContendedTime += dur;
|
|
469
|
+
if (s.nonParallel > 0) {
|
|
470
|
+
nonParallelContendedTime += dur;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
const recoverableBySlots = Math.min(overhead, slotContendedTime);
|
|
475
|
+
const nonParallelRecoverable = Math.min(recoverableBySlots, nonParallelContendedTime);
|
|
476
|
+
const ordinaryBySlots = recoverableBySlots - nonParallelRecoverable;
|
|
477
|
+
const machineBound = parallel < cores
|
|
478
|
+
? Math.max(0, totalWork / cores - criticalPathDuration)
|
|
479
|
+
: Infinity;
|
|
480
|
+
const ordinaryByMachines = Math.min(ordinaryBySlots, machineBound);
|
|
481
|
+
return {
|
|
482
|
+
coordinatorOverhead: overhead - recoverableBySlots,
|
|
483
|
+
recoverableByMachines: nonParallelRecoverable + ordinaryByMachines,
|
|
484
|
+
recoverableByParallel: ordinaryBySlots - ordinaryByMachines,
|
|
485
|
+
};
|
|
486
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { BatchInfo, PerformanceSummaryPayload } from '../../native';
|
|
2
|
+
import { TaskGraph } from '../../config/task-graph';
|
|
3
|
+
import { LifeCycle, TaskResult } from '../life-cycle';
|
|
4
|
+
import { type PerformanceLifeCycleOptions, type PerformanceSummary } from './performance-analysis';
|
|
5
|
+
/**
|
|
6
|
+
* Measures how much wall-clock a run loses to parallelism contention versus its
|
|
7
|
+
* critical-path floor, and reports it at the end of a run. Added on every run by
|
|
8
|
+
* `constructLifeCycles`, but only emitted where the report is flushed (the CLI
|
|
9
|
+
* `invokeTasksRunner` path) — the programmatic `init-tasks-runner` path collects
|
|
10
|
+
* timings but never displays them.
|
|
11
|
+
*
|
|
12
|
+
* overhead = runDuration − criticalPathDuration, split by CAUSE off the occupancy
|
|
13
|
+
* timeline: slot-queued time (recoverable by parallelism / machines) versus
|
|
14
|
+
* coordinator time (hashing, scheduling, continuous-dep waits).
|
|
15
|
+
*
|
|
16
|
+
* Scope: discrete tasks only. Continuous tasks (no end time) are excluded; a
|
|
17
|
+
* discrete task's wait for a continuous dependency to start is eligibility, not
|
|
18
|
+
* contention.
|
|
19
|
+
*/
|
|
20
|
+
export declare class PerformanceLifeCycle implements LifeCycle {
|
|
21
|
+
private readonly taskGraph;
|
|
22
|
+
private readonly options;
|
|
23
|
+
private readonly timings;
|
|
24
|
+
/** taskId → terminal status (cache hit vs ran), for the cache summary. */
|
|
25
|
+
private readonly statuses;
|
|
26
|
+
/** taskId → other tasks in its batch (batches run sequentially). */
|
|
27
|
+
private readonly batchSiblings;
|
|
28
|
+
/** Resolved `--parallel`, set by the runner via {@link startCommand}'s second arg once the thread pool is sized. */
|
|
29
|
+
private parallel;
|
|
30
|
+
constructor(taskGraph: TaskGraph, options?: PerformanceLifeCycleOptions);
|
|
31
|
+
/**
|
|
32
|
+
* The runner passes the resolved `--parallel` (getThreadPoolSize's `discrete`) as the
|
|
33
|
+
* second arg; the first (thread count) is for the TUI and ignored here.
|
|
34
|
+
*/
|
|
35
|
+
startCommand(_threadCount?: number, parallel?: number): void;
|
|
36
|
+
registerRunningBatch(_batchId: string, batchInfo: BatchInfo): void;
|
|
37
|
+
endTasks(taskResults: TaskResult[]): void;
|
|
38
|
+
private entry;
|
|
39
|
+
/** Analyze the collected timings into a structured summary, or `null` when no discrete task timings were recorded. */
|
|
40
|
+
getSummary(): PerformanceSummary | null;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Structured report for the TUI's exit-countdown popup, or null when nothing to
|
|
44
|
+
* show. Clears the active lifecycle so the popup owns the report and a later terminal
|
|
45
|
+
* flush can't re-print it. Best-effort: a throw degrades to null.
|
|
46
|
+
*/
|
|
47
|
+
export declare function getPerformanceSummaryPayload(): PerformanceSummaryPayload | null;
|
|
48
|
+
/**
|
|
49
|
+
* The performance report payload for `endCommand`'s TUI exit popup, or undefined when the
|
|
50
|
+
* report should instead be flushed to the terminal — non-TUI runs, or a single task (the
|
|
51
|
+
* complement of run-command's flush gate). Reading it consumes the report so the flush
|
|
52
|
+
* won't reprint it.
|
|
53
|
+
*/
|
|
54
|
+
export declare function getPerformanceReport(taskCount: number): PerformanceSummaryPayload | undefined;
|
|
55
|
+
/**
|
|
56
|
+
* Print the performance report (if enabled) after the run summary. Called once the
|
|
57
|
+
* terminal is restored, so it appears in every output mode including the TUI.
|
|
58
|
+
*/
|
|
59
|
+
export declare function flushPerformanceReport(): void;
|