UNPKG

nx

Version:

The core Nx plugin contains the core functionality of Nx like the project graph, nx commands and task orchestration.

509 lines (508 loc) 21.6 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.PerformanceAnalysis = void 0; exports.mergeIntervals = mergeIntervals; exports.overlap = overlap; exports.preDispatchHashTime = preDispatchHashTime; exports.buildTimespans = buildTimespans; const tslib_1 = require("tslib"); const os = tslib_1.__importStar(require("node:os")); const node_perf_hooks_1 = require("node:perf_hooks"); const nx_cloud_utils_1 = require("../../utils/nx-cloud-utils"); const is_ci_1 = require("../../utils/is-ci"); const performance_report_1 = require("./performance-report"); const CACHE_HIT_STATUSES = new Set([ 'local-cache', 'local-cache-kept-existing', 'remote-cache', ]); /** 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). */ const PRE_DISPATCH_HASH_GAP = 1000; /** A critical-path task shorter than this fraction of the path is noise, not a speed-up target. */ const CRITICAL_PATH_TOP_MIN_FRACTION = 0.2; /** * Pure analysis over one finished run's collected timings — no lifecycle state and no * mutation. Built once per run from the {@link PerformanceLifeCycle}'s accumulated * maps; everything here is a function of that snapshot plus the environment it reads. */ class PerformanceAnalysis { constructor(timings, statuses, taskGraph, batchSiblings, /** Resolved `--parallel` (getThreadPoolSize's `discrete`), set on the lifecycle by the runner; falls back to 1. */ parallel, options) { this.timings = timings; this.statuses = statuses; this.taskGraph = taskGraph; this.batchSiblings = batchSiblings; this.parallel = parallel; this.options = options; } /** * Whether a remote (Nx Cloud) cache is active, from the nx.json handed in at * construction. Assumes no remote cache when it wasn't provided rather than * re-reading nx.json from disk — the CLI path always provides it. */ remoteCacheEnabled() { return this.options.nxJson ? (0, nx_cloud_utils_1.isNxCloudUsed)(this.options.nxJson) : false; } // === Analysis (pure functions of the collected timings + task graph) === /** Discrete tasks that ran and have a start + end timestamp. */ timedTasks() { const result = []; for (const [id, t] of this.timings) { if (!t.continuous && t.startTime != null && t.endTime != null) { result.push({ id, start: t.startTime, end: t.endTime, // A parallelism:false task occupies the whole pool; carried here so the // occupancy timeline (buildTimespans) stays a pure function of `timed`. nonParallel: this.taskGraph.tasks[id]?.parallelism === false, }); } } return result; } /** * Longest-duration chain through the dependency DAG — the floor with unlimited * slots. `finishEstimate(t) = duration[t] + max(finishEstimate(p) for predecessors * p)`. Predecessors are real dependencies *plus* earlier batch siblings (a batch runs * sequentially in one process, so that ordering is part of the floor too). On equal * finish estimates keep the longer-running predecessor. */ criticalPath(durations) { const ctx = { durations, memo: new Map(), pred: new Map(), visiting: new Set(), }; let terminal = null; let terminalFinish = -1; let terminalDur = -1; for (const id of durations.keys()) { const f = this.finishEstimate(id, ctx); const d = durations.get(id) ?? 0; if (finishesLater(f, d, terminalFinish, terminalDur)) { terminalFinish = f; terminalDur = d; terminal = id; } } return { path: tracePath(terminal, ctx.pred), duration: terminal == null ? 0 : terminalFinish, }; } /** * Real predecessors of `id`: dependencies plus any earlier batch sibling that FINISHED * before `id` started. A batch runs sequentially in one process, so that ordering is * part of the floor; concurrent batch executors aren't, and chaining them would inflate * it. (Matches readyTime.) */ predecessorsFor(id, durations) { const start = this.timings.get(id)?.startTime ?? Infinity; const deps = (this.taskGraph.dependencies[id] ?? []).filter((d) => durations.has(d)); const siblings = (this.batchSiblings.get(id) ?? []).filter((s) => { const sEnd = this.timings.get(s)?.endTime; return durations.has(s) && sEnd != null && sEnd <= start; }); return [...deps, ...siblings]; } /** * Longest finish time of any chain reaching `id` — its duration plus the best * predecessor's finish estimate — memoized in `ctx`. `ctx.visiting` guards a cycle in * a malformed graph (the back-edge contributes nothing). */ finishEstimate(id, ctx) { const cached = ctx.memo.get(id); if (cached != null) { return cached; } if (ctx.visiting.has(id)) { return 0; } ctx.visiting.add(id); let best = 0; let bestPred = null; let bestDur = -1; for (const p of this.predecessorsFor(id, ctx.durations)) { const f = this.finishEstimate(p, ctx); const d = ctx.durations.get(p) ?? 0; if (finishesLater(f, d, best, bestDur)) { best = f; bestPred = p; bestDur = d; } } ctx.visiting.delete(id); const value = best + (ctx.durations.get(id) ?? 0); ctx.memo.set(id, value); ctx.pred.set(id, bestPred); return value; } /** * Earliest this task became eligible, independent of slots: the latest of run * start, dependency ends, any continuous dependency's *start* (an ordering * constraint, not contention), and any earlier batch sibling's end. */ readyTime(id, runStart) { const start = this.timings.get(id)?.startTime; let result = runStart; for (const dep of this.taskGraph.dependencies[id] ?? []) { const end = this.timings.get(dep)?.endTime; if (end != null) { result = Math.max(result, end); } } for (const cdep of this.taskGraph.continuousDependencies?.[id] ?? []) { const cStart = this.timings.get(cdep)?.startTime; if (cStart != null) { result = Math.max(result, cStart); } } for (const sibling of this.batchSiblings.get(id) ?? []) { const end = this.timings.get(sibling)?.endTime; if (end != null && start != null && end <= start) { result = Math.max(result, end); } } return result; } /** * Longest critical-path tasks that RAN (desc, capped at 3). Cache hits are excluded * (their duration is just restore time); no-status tasks (synthetic test runs) are * kept. */ computeCriticalPathTop(criticalPathTasks, durations, criticalPathDuration) { return (criticalPathTasks .filter((id) => { const status = this.statuses.get(id); return !status || !CACHE_HIT_STATUSES.has(status); }) .map((id) => ({ id, duration: durations.get(id) ?? 0 })) // Only tasks that meaningfully shape the path are worth speeding up. .filter((t) => t.duration >= CRITICAL_PATH_TOP_MIN_FRACTION * criticalPathDuration) .sort((a, b) => b.duration - a.duration) .slice(0, 3)); } /** Cache outcome: tasks restored (`cacheHits`) and the total with a cache outcome (`cacheableCount` = hits + ran). No-status tasks count for neither. */ computeCacheStats() { let cacheHits = 0; let cacheRan = 0; for (const status of this.statuses.values()) { if (CACHE_HIT_STATUSES.has(status)) { cacheHits++; } else if (status === 'success') { cacheRan++; } } return { cacheHits, cacheableCount: cacheHits + cacheRan }; } /** * The ids of tasks that failed during the run, slowest first, for the GitHub Actions * summary's failed-tasks list. Continuous tasks and tasks without a complete window are * excluded (a failed task ran to a non-zero exit, so it has both timestamps). Duration * orders the list but isn't shown — for a failure, which task failed is what matters. */ computeFailedTasks() { const rows = []; for (const [id, timing] of this.timings) { if (timing.continuous || timing.startTime == null || timing.endTime == null || this.statuses.get(id) !== 'failure') { continue; } rows.push({ id, duration: Math.max(0, timing.endTime - timing.startTime), }); } return rows.sort((a, b) => b.duration - a.duration).map((r) => r.id); } /** The structured performance summary, or `null` when no discrete task timings were recorded. */ summary() { const timed = this.timedTasks(); if (timed.length === 0) { return null; } const { durations, totalWork, runStart, taskWindow } = computeRunWindow(timed); const eligible = new Map(); for (const { id } of timed) { eligible.set(id, this.readyTime(id, runStart)); } const { path: criticalPathTasks, duration: criticalPathDuration } = this.criticalPath(durations); const parallel = Math.max(1, this.parallel); const cores = detectCoreCount(); const timespans = buildTimespans(timed, eligible, parallel); const hashWindows = collectHashWindows(); // Pre-dispatch hashing is part of overhead but not the slot split. const runDuration = taskWindow + preDispatchHashTime(runStart, hashWindows); const overhead = Math.max(0, runDuration - criticalPathDuration); const { coordinatorOverhead, recoverableByMachines, recoverableByParallel, } = splitOverhead({ timespans, parallel, overhead, totalWork, cores, criticalPathDuration, }); const criticalPathTop = this.computeCriticalPathTop(criticalPathTasks, durations, criticalPathDuration); const { cacheHits, cacheableCount } = this.computeCacheStats(); // `skipNxCache` already folds in NX_SKIP_NX_CACHE / NX_DISABLE_NX_CACHE // (normalized in command-line-utils) — don't re-read those env vars here. const cacheSkipped = this.options.skipNxCache === true; const remoteCacheEnabled = this.remoteCacheEnabled(); const cloudOptedOut = this.options.nxJson ? !!(0, nx_cloud_utils_1.isNxCloudDisabled)(this.options.nxJson) : false; // Coordinator-dominated: hashing/scheduling outweighs task work by >3x the // critical path, which keeps cold runs critical-path-bound. const coordinatorDominated = coordinatorOverhead >= performance_report_1.MEANINGFUL_OVERHEAD && coordinatorOverhead > 3 * criticalPathDuration; // isCiEnv() checks the full set of CI env vars, not just `CI`. const isCI = !!(0, is_ci_1.isCI)(); // TODO: source from the light client's isDistributedExecution() rather than the env var. const distributing = !!process.env.NX_CLOUD_DISTRIBUTED_EXECUTION_AGENT_COUNT; return { runDuration, criticalPathDuration, criticalPathTaskCount: criticalPathTasks.length, criticalPathTop, failedTasks: this.computeFailedTasks(), overhead, recoverableByParallel, recoverableByMachines, coordinatorOverhead, parallel, cores, isCI, // Can only start distributing in CI when not already doing so — and never // suggest Nx Agents to a workspace that opted out of Nx Cloud. canDistribute: isCI && !distributing && !cloudOptedOut, distributing, coordinatorDominated, cacheHits, cacheableCount, cacheSkipped, remoteCacheEnabled, cloudOptedOut, }; } } exports.PerformanceAnalysis = PerformanceAnalysis; // === Analysis helpers (module-level; no instance state) === /** * True when a `(finishEstimate, duration)` candidate finishes later than the current * best — higher finish wins, ties broken by the longer-running task. */ function finishesLater(finish, dur, bestFinish, bestDur) { return finish > bestFinish || (finish === bestFinish && dur > bestDur); } /** * Follow the recorded best-predecessor links from `end` back to the root, returning * the path root → end. `seen` guards against a cycle in a malformed `pred` chain. */ function tracePath(end, pred) { const path = []; const seen = new Set(); for (let node = end; node != null && !seen.has(node); node = pred.get(node) ?? null) { seen.add(node); path.unshift(node); } return path; } /** Absolute-epoch [start, end] hashing windows from nx's existing `hash*` perf measures; [] if the performance API is unavailable. */ function collectHashWindows() { try { const origin = node_perf_hooks_1.performance.timeOrigin; return node_perf_hooks_1.performance .getEntriesByType('measure') .filter((m) => m.name.startsWith('hash')) .map((m) => [ origin + m.startTime, origin + m.startTime + m.duration, ]); } catch { return []; } } /** * Union a set of `[start, end]` intervals into an ordered, non-overlapping set, e.g. * `[[1, 3], [2, 5]]` → `[[1, 5]]`. Sort by start, then sweep once: each interval * either extends the previous merged one (if it overlaps or touches it) or begins a * new one. */ function mergeIntervals(intervals) { const byStart = [...intervals].sort((a, b) => a[0] - b[0]); const merged = []; for (const [start, end] of byStart) { const previous = merged[merged.length - 1]; // `previous[1]` is the running end of the last merged interval. This interval // overlaps or touches it when its start is at or before that end — so absorb it // by extending the end. Otherwise it's disjoint and starts a new interval. const overlapsPrevious = previous != null && start <= previous[1]; if (overlapsPrevious) { previous[1] = Math.max(previous[1], end); } else { merged.push([start, end]); } } return merged; } /** Total time [a, b] overlaps the given (unsorted, possibly overlapping) intervals. */ function overlap(a, b, intervals) { let sum = 0; for (const [s, e] of intervals) { sum += Math.max(0, Math.min(b, e) - Math.max(a, s)); } return sum; } /** * Coordinator hashing wall-clock that ran *before* the first task started — the run * window (first task start → last task end) would otherwise miss it. Matters on a * cached run where hashing dominates but the tasks restore in milliseconds. * * `hashWindows` are absolute `[start, end]` spans from nx's `hash*` perf measures. */ function preDispatchHashTime(firstTaskStart, hashWindows) { // Keep only the part of each window before the first task, drop any that become // empty, then union overlaps so shared time isn't counted twice. const preDispatchWindows = mergeIntervals(hashWindows .map(([start, end]) => [ start, Math.min(end, firstTaskStart), ]) .filter(([start, end]) => end > start)); // Walk newest → oldest, adding each window while it stays contiguous with the one // after it. Stop at the first gap wider than PRE_DISPATCH_HASH_GAP — anything older // is stale hashing from an earlier run in this process (e.g. the daemon's). let total = 0; let contiguousFrom = firstTaskStart; for (let i = preDispatchWindows.length - 1; i >= 0; i--) { const [start, end] = preDispatchWindows[i]; const gapTooLarge = end < contiguousFrom - PRE_DISPATCH_HASH_GAP; if (gapTooLarge) { break; } total += end - start; contiguousFrom = start; } return total; } /** Per-task durations, total work, and the run window from the timed tasks. */ function computeRunWindow(timed) { const durations = new Map(); let totalWork = 0; let runStart = Infinity; let runEnd = -Infinity; for (const { id, start, end } of timed) { const duration = Math.max(0, end - start); durations.set(id, duration); totalWork += duration; runStart = Math.min(runStart, start); runEnd = Math.max(runEnd, end); } return { durations, totalWork, runStart, taskWindow: Math.max(0, runEnd - runStart), }; } /** 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. */ function detectCoreCount() { return typeof os.availableParallelism === 'function' ? os.availableParallelism() : os.cpus().length; } /** * The occupancy timeline (contiguous {@link Timespan}s) — single source of truth for * slot contention. A `parallelism: false` task occupies the whole pool. Integrates * over time rather than sampling one instant, so it's robust to when tasks hand off. * * Builds it via a delta sweep: each task bumps a counter up at its start and down at * its end, then we walk the sorted boundaries accumulating the running totals into one * timespan per gap. */ function buildTimespans(timed, eligible, parallel) { // Per-timestamp +/- deltas the sweep integrates into timespans: busy slots (occ), // eligible-but-waiting tasks, and parallelism:false tasks that hold the whole pool. const occDelta = new Map(); const waitDelta = new Map(); const nonParallelDelta = new Map(); const addDelta = (deltas, at, delta) => deltas.set(at, (deltas.get(at) ?? 0) + delta); for (const { id, start, end, nonParallel: isNonParallel } of timed) { // A parallelism:false task holds every slot, so it weighs the whole pool. const weight = isNonParallel ? parallel : 1; addDelta(occDelta, start, weight); addDelta(occDelta, end, -weight); if (isNonParallel) { addDelta(nonParallelDelta, start, 1); addDelta(nonParallelDelta, end, -1); } const elig = eligible.get(id) ?? start; if (elig < start) { addDelta(waitDelta, elig, 1); addDelta(waitDelta, start, -1); } } const times = Array.from(new Set([ ...occDelta.keys(), ...waitDelta.keys(), ...nonParallelDelta.keys(), ])).sort((a, b) => a - b); const timespans = []; let occ = 0; let waiting = 0; let nonParallel = 0; for (let i = 0; i < times.length; i++) { occ += occDelta.get(times[i]) ?? 0; waiting += waitDelta.get(times[i]) ?? 0; nonParallel += nonParallelDelta.get(times[i]) ?? 0; const next = times[i + 1]; if (next != null && next > times[i]) { timespans.push({ start: times[i], end: next, occ, waiting, nonParallel }); } } return timespans; } /** * Split overhead off the occupancy timeline into three buckets that sum to overhead: * coordinator time, and slot contention recoverable by more machines vs by a higher * --parallel. * * Slot contention is wall-clock where every slot was busy with a backlog (occ ≥ * parallel, waiting > 0). Contention while a `parallelism: false` task held the whole * pool goes to machines only (a higher local --parallel can't recover it). Of the * rest, --parallel helps up to the volume the cores can absorb; the overflow * (totalWork/cores − floor) needs more machines. */ function splitOverhead({ timespans, parallel, overhead, totalWork, cores, criticalPathDuration, }) { let slotContendedTime = 0; let nonParallelContendedTime = 0; for (const s of timespans) { if (s.occ >= parallel && s.waiting > 0) { const dur = s.end - s.start; slotContendedTime += dur; if (s.nonParallel > 0) { nonParallelContendedTime += dur; } } } const recoverableBySlots = Math.min(overhead, slotContendedTime); const nonParallelRecoverable = Math.min(recoverableBySlots, nonParallelContendedTime); const ordinaryBySlots = recoverableBySlots - nonParallelRecoverable; const machineBound = parallel < cores ? Math.max(0, totalWork / cores - criticalPathDuration) : Infinity; const ordinaryByMachines = Math.min(ordinaryBySlots, machineBound); return { coordinatorOverhead: overhead - recoverableBySlots, recoverableByMachines: nonParallelRecoverable + ordinaryByMachines, recoverableByParallel: ordinaryBySlots - ordinaryByMachines, }; }