From e8fb9b4380bca680efcc33c4e66a47501880f600 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 29 Apr 2026 13:11:37 -0700 Subject: [PATCH] fix(profile): expose sampler availability --- src/ci-summary.js | 36 ++++++++++++++++- src/import-loop-profile.js | 40 +++++++++++++++++-- src/process-profile.js | 59 ++++++++++++++++++--------- src/runtime-profile.js | 82 +++++++++++++++++++++++++++++++------- test/ci-summary.test.js | 2 + 5 files changed, 180 insertions(+), 39 deletions(-) diff --git a/src/ci-summary.js b/src/ci-summary.js index fc90f47..958502d 100644 --- a/src/ci-summary.js +++ b/src/ci-summary.js @@ -58,6 +58,8 @@ export async function buildCiSummary(options = {}) { importLoopP95Ms: reports.importLoop?.summary?.p95WallMs ?? 0, importLoopMaxRssMb: reports.importLoop?.summary?.maxPeakRssMb ?? 0, importLoopMaxCpuMs: reports.importLoop?.summary?.maxCpuMsEstimate ?? 0, + importLoopRssSampleCount: metricSampleCount(reports.importLoop, "rss", "maxPeakRssMb"), + importLoopCpuSampleCount: metricSampleCount(reports.importLoop, "cpu", "maxCpuMsEstimate"), }, topIssues: topIssues(reports.compatibility), refRegressions: (reports.refDiff?.regressions ?? []).slice(0, 20), @@ -148,7 +150,7 @@ export function renderCiSummaryMarkdown(summary) { ["Jiti loader candidates", summary.summary.loaderJitiCandidates], [ "Import loop", - `p50 ${summary.summary.importLoopP50Ms} ms / p95 ${summary.summary.importLoopP95Ms} ms / max RSS ${summary.summary.importLoopMaxRssMb} MB / CPU ${summary.summary.importLoopMaxCpuMs} ms`, + `p50 ${summary.summary.importLoopP50Ms} ms / p95 ${summary.summary.importLoopP95Ms} ms / max RSS ${formatSampledMetric(summary.summary.importLoopMaxRssMb, summary.summary.importLoopRssSampleCount)} / CPU ${formatSampledMetric(summary.summary.importLoopMaxCpuMs, summary.summary.importLoopCpuSampleCount, "ms")}`, ], ], ["Metric", "Value"], @@ -221,3 +223,35 @@ function topIssues(report) { function markdownTable(rows, headers) { return renderPaddedMarkdownTable(rows, headers, { nullValue: "-" }); } + +function metricSampleCount(report, kind, maxMetric) { + const summaryKey = kind === "rss" ? "rssSampleCount" : "cpuSampleCount"; + const summaryCount = report?.summary?.[summaryKey]; + if (Number.isFinite(summaryCount)) { + return summaryCount; + } + const sampleCount = inferSampleCount(report?.samples, kind); + if (sampleCount > 0) { + return sampleCount; + } + return (report?.summary?.[maxMetric] ?? 0) > 0 ? 1 : 0; +} + +function inferSampleCount(samples = [], kind) { + if (!Array.isArray(samples)) { + return 0; + } + return samples.reduce((sum, sample) => { + if (kind === "rss") { + return sum + (sample.rssSampleCount ?? (sample.peakRssMb > 0 ? 1 : 0)); + } + return sum + (sample.cpuSampleCount ?? 0); + }, 0); +} + +function formatSampledMetric(value, count, unit = "MB") { + if ((count ?? 0) <= 0) { + return "n/a"; + } + return `${value} ${unit}`; +} diff --git a/src/import-loop-profile.js b/src/import-loop-profile.js index 2067723..69f6d31 100644 --- a/src/import-loop-profile.js +++ b/src/import-loop-profile.js @@ -30,6 +30,9 @@ export async function buildImportLoopProfile(options = {}) { } const wallMs = samples.map((sample) => sample.wallMs).sort((left, right) => left - right); + const rssSampleCount = samples.reduce((sum, sample) => sum + (sample.rssSampleCount ?? (sample.peakRssMb > 0 ? 1 : 0)), 0); + const cpuSampleCount = samples.reduce((sum, sample) => sum + (sample.cpuSampleCount ?? 0), 0); + const statSampleCount = samples.reduce((sum, sample) => sum + (sample.statSampleCount ?? 0), 0); return { generatedAt: options.generatedAt ?? defaultImportLoopProfileOptions.generatedAt, mode: options.mode ?? "subprocess-cold-import-loop", @@ -40,6 +43,9 @@ export async function buildImportLoopProfile(options = {}) { p95WallMs: percentile(wallMs, 0.95), maxPeakRssMb: Math.max(0, ...samples.map((sample) => sample.peakRssMb)), maxCpuMsEstimate: Math.max(0, ...samples.map((sample) => sample.cpuMsEstimate)), + statSampleCount, + rssSampleCount, + cpuSampleCount, capturedCount: samples.reduce((sum, sample) => sum + sample.capturedCount, 0), failCount: samples.filter((sample) => sample.exitCode !== 0 || sample.status !== "captured").length, }, @@ -85,7 +91,7 @@ export function renderImportLoopProfileMarkdown(report, options = {}) { "", "## Summary", "", - markdownTable(Object.entries(report.summary).map(([key, value]) => [key, value]), ["Metric", "Value"]), + markdownTable(summaryRows(report), ["Metric", "Value"]), "", "## Samples", "", @@ -95,11 +101,12 @@ export function renderImportLoopProfileMarkdown(report, options = {}) { sample.status, sample.capturedCount, `${sample.wallMs} ms`, - `${sample.peakRssMb} MB`, - `${sample.cpuMsEstimate} ms`, + formatSampledMetric(sample.peakRssMb, sample.rssSampleCount), + formatSampledMetric(sample.cpuMsEstimate, sample.cpuSampleCount, "ms"), + `${sample.rssSampleCount ?? 0}/${sample.cpuSampleCount ?? 0}`, sample.exitCode, ]), - ["Run", "Status", "Captured", "Wall", "Peak RSS", "CPU Estimate", "Exit"], + ["Run", "Status", "Captured", "Wall", "Peak RSS", "CPU Estimate", "RSS/CPU samples", "Exit"], ), ].join("\n"); } @@ -130,10 +137,35 @@ async function runCaptureSample(options) { peakRssMb: profile.peakRssMb, peakCpuPercent: profile.peakCpuPercent, cpuMsEstimate: profile.cpuMsEstimate, + statSampleCount: profile.statSampleCount, + rssSampleCount: profile.rssSampleCount, + cpuSampleCount: profile.cpuSampleCount, stderrPreview: profile.stderrPreview, }; } +function summaryRows(report) { + return [ + ["runs", report.summary.runs], + ["p50WallMs", report.summary.p50WallMs], + ["p95WallMs", report.summary.p95WallMs], + ["maxPeakRssMb", formatSampledMetric(report.summary.maxPeakRssMb, report.summary.rssSampleCount)], + ["maxCpuMsEstimate", formatSampledMetric(report.summary.maxCpuMsEstimate, report.summary.cpuSampleCount, "ms")], + ["statSampleCount", report.summary.statSampleCount ?? 0], + ["rssSampleCount", report.summary.rssSampleCount ?? 0], + ["cpuSampleCount", report.summary.cpuSampleCount ?? 0], + ["capturedCount", report.summary.capturedCount], + ["failCount", report.summary.failCount], + ]; +} + +function formatSampledMetric(value, count, unit = "MB") { + if ((count ?? 0) <= 0) { + return "n/a"; + } + return `${value} ${unit}`; +} + function buildCaptureCommand(options) { if (typeof options.captureCommand === "function") { return options.captureCommand({ diff --git a/src/process-profile.js b/src/process-profile.js index a3ed92e..fb9b11f 100644 --- a/src/process-profile.js +++ b/src/process-profile.js @@ -7,8 +7,12 @@ export async function runProfiledProcess(options) { let firstRssKb = 0; let peakRssKb = 0; let peakCpuPercent = 0; + let statSampleCount = 0; + let rssSampleCount = 0; + let cpuSampleCount = 0; const cpuSamples = []; let pollInFlight = false; + const pendingStats = new Set(); const child = spawn(options.command, options.args ?? [], { cwd: options.cwd, @@ -21,27 +25,43 @@ export async function runProfiledProcess(options) { child.stderr?.on("data", (chunk) => stderr.push(chunk)); const recordStats = (stats) => { - if (stats.rssKb > 0 && firstRssKb === 0) { + if (stats.rssAvailable || stats.cpuAvailable) { + statSampleCount += 1; + } + if (stats.rssAvailable) { + rssSampleCount += 1; + } + if (stats.cpuAvailable) { + cpuSampleCount += 1; + } + if (stats.rssAvailable && stats.rssKb > 0 && firstRssKb === 0) { firstRssKb = stats.rssKb; } - peakRssKb = Math.max(peakRssKb, stats.rssKb); - peakCpuPercent = Math.max(peakCpuPercent, stats.cpuPercent); - if (stats.cpuPercent > 0) { + if (stats.rssAvailable) { + peakRssKb = Math.max(peakRssKb, stats.rssKb); + } + if (stats.cpuAvailable) { + peakCpuPercent = Math.max(peakCpuPercent, stats.cpuPercent); cpuSamples.push(stats.cpuPercent); } }; - const poll = setInterval(() => { + const sampleStats = () => { if (pollInFlight) { return; } pollInFlight = true; - readProcessStats(child.pid) + const pending = readProcessStats(child.pid) .then(recordStats) .finally(() => { pollInFlight = false; + pendingStats.delete(pending); }); - }, options.pollMs ?? 100); + pendingStats.add(pending); + }; + + sampleStats(); + const poll = setInterval(sampleStats, options.pollMs ?? 25); const exitCode = await new Promise((resolve, reject) => { child.on("error", (error) => { @@ -51,16 +71,10 @@ export async function runProfiledProcess(options) { child.on("exit", (code) => resolve(code ?? 1)); }); clearInterval(poll); + await Promise.allSettled([...pendingStats]); const finalStats = await readProcessStats(child.pid); - if (finalStats.rssKb > 0 && firstRssKb === 0) { - firstRssKb = finalStats.rssKb; - } - peakRssKb = Math.max(peakRssKb, finalStats.rssKb); - peakCpuPercent = Math.max(peakCpuPercent, finalStats.cpuPercent); - if (finalStats.cpuPercent > 0) { - cpuSamples.push(finalStats.cpuPercent); - } + recordStats(finalStats); const wallMs = Math.round(performance.now() - start); const averageCpuPercent = @@ -79,6 +93,9 @@ export async function runProfiledProcess(options) { peakCpuPercent: Math.round(peakCpuPercent * 10) / 10, cpuMsEstimate: Math.round((wallMs * cpuPercentForEstimate) / 100), harnessHeapDeltaMb: Math.round((heapUsedMb() - heapStartMb) * 10) / 10, + statSampleCount, + rssSampleCount, + cpuSampleCount, exitCode, stdoutPreview: previewLines(stdout), stderrPreview: previewLines(stderr), @@ -87,7 +104,7 @@ export async function runProfiledProcess(options) { async function readProcessStats(pid) { if (!pid || process.platform === "win32") { - return { rssKb: 0, cpuPercent: 0 }; + return { rssAvailable: false, rssKb: 0, cpuAvailable: false, cpuPercent: 0 }; } return new Promise((resolve) => { const ps = spawn("ps", ["-o", "rss=", "-o", "%cpu=", "-p", String(pid)], { @@ -95,14 +112,18 @@ async function readProcessStats(pid) { }); const chunks = []; ps.stdout.on("data", (chunk) => chunks.push(chunk)); - ps.on("error", () => resolve({ rssKb: 0, cpuPercent: 0 })); + ps.on("error", () => resolve({ rssAvailable: false, rssKb: 0, cpuAvailable: false, cpuPercent: 0 })); ps.on("exit", () => { const [rssRaw, cpuRaw] = Buffer.concat(chunks).toString("utf8").trim().split(/\s+/); const rssKb = Number.parseInt(rssRaw, 10); const cpuPercent = Number.parseFloat(cpuRaw); + const rssAvailable = Number.isFinite(rssKb); + const cpuAvailable = Number.isFinite(cpuPercent); resolve({ - rssKb: Number.isFinite(rssKb) ? rssKb : 0, - cpuPercent: Number.isFinite(cpuPercent) ? cpuPercent : 0, + rssAvailable, + rssKb: rssAvailable ? rssKb : 0, + cpuAvailable, + cpuPercent: cpuAvailable ? cpuPercent : 0, }); }); }); diff --git a/src/runtime-profile.js b/src/runtime-profile.js index 5af2495..1030af7 100644 --- a/src/runtime-profile.js +++ b/src/runtime-profile.js @@ -46,8 +46,8 @@ export async function buildRuntimeProfile(options = {}) { os: process.platform, arch: process.arch, node: process.version, - rssSampler: process.platform === "win32" ? "unavailable" : "ps", - cpuSampler: process.platform === "win32" ? "unavailable" : "ps-percent", + rssSampler: process.platform === "win32" ? "unavailable" : "ps-immediate-25ms", + cpuSampler: process.platform === "win32" ? "unavailable" : "ps-percent-immediate-25ms", }, summary: summarizeProfile(commands), groups: summarizeCommandGroups(commands), @@ -65,8 +65,8 @@ export function validateRuntimeProfile(profile) { errors.push(`${command.id}: missing wall time`); } } - if (profile.platform?.rssSampler !== "unavailable" && profile.commands.every((command) => command.peakRssMb.max <= 0)) { - errors.push("all commands are missing peak RSS"); + if (profile.platform?.rssSampler !== "unavailable" && profile.commands.every((command) => !hasRssSample(command))) { + errors.push("all commands are missing peak RSS samples"); } return errors; } @@ -98,10 +98,17 @@ export function renderRuntimeProfileMarkdown(profile, options = {}) { [ ["Commands", profile.summary.commandCount], ["P50 wall time", `${profile.summary.p50WallMs} ms`], - ["P95 wall time", `${profile.summary.p95WallMs} ms`], - ["Max peak RSS", `${profile.summary.maxPeakRssMb} MB`], - ["Max RSS delta", `${profile.summary.maxRssDeltaMb} MB`], - ["Max CPU estimate", `${profile.summary.maxCpuMsEstimate} ms`], + ["Command P95 wall time", `${profile.summary.p95WallMs} ms`], + ["Wall time basis", profile.summary.wallTimeBasis ?? "command-median-p95"], + ["Profile samples", profile.summary.sampleCount ?? sampleCount(profile.commands)], + ["RSS samples", profile.summary.rssSampleCount ?? rssSampleCount(profile.commands)], + ["CPU samples", profile.summary.cpuSampleCount ?? cpuSampleCount(profile.commands)], + ["Max peak RSS", formatSampledMetric(profile.summary.maxPeakRssMb, profile.summary.rssSampleCount ?? rssSampleCount(profile.commands))], + ["Max RSS delta", formatSampledMetric(profile.summary.maxRssDeltaMb, profile.summary.rssSampleCount ?? rssSampleCount(profile.commands))], + [ + "Max CPU estimate", + formatSampledMetric(profile.summary.maxCpuMsEstimate, profile.summary.cpuSampleCount ?? cpuSampleCount(profile.commands), "ms"), + ], ["Max harness heap delta", `${profile.summary.maxHarnessHeapDeltaMb} MB`], ], ["Metric", "Value"], @@ -129,13 +136,14 @@ export function renderRuntimeProfileMarkdown(profile, options = {}) { command.label, `${command.wallMs.median} ms`, `${command.wallMs.max} ms`, - `${command.peakRssMb.max} MB`, - `${command.rssDeltaMb.max} MB`, - `${command.cpuMsEstimate.max} ms`, + formatSampledMetric(command.peakRssMb.max, command.rssSampleCount), + formatSampledMetric(command.rssDeltaMb.max, command.rssSampleCount), + formatSampledMetric(command.cpuMsEstimate.max, command.cpuSampleCount, "ms"), `${command.harnessHeapDeltaMb.max} MB`, + `${command.rssSampleCount ?? 0}/${command.cpuSampleCount ?? 0}`, command.exitCodes.join(", "), ]), - ["ID", "Label", "Median wall", "Max wall", "Max peak RSS", "Max RSS delta", "CPU estimate", "Heap delta", "Exit codes"], + ["ID", "Label", "Median wall", "Max wall", "Max peak RSS", "Max RSS delta", "CPU estimate", "Heap delta", "RSS/CPU samples", "Exit codes"], ), "", "## Category Rollups", @@ -146,11 +154,12 @@ export function renderRuntimeProfileMarkdown(profile, options = {}) { group.commandCount, `${group.p50WallMs} ms`, `${group.p95WallMs} ms`, - `${group.maxPeakRssMb} MB`, - `${group.maxCpuMsEstimate} ms`, + formatSampledMetric(group.maxPeakRssMb, group.rssSampleCount), + formatSampledMetric(group.maxCpuMsEstimate, group.cpuSampleCount, "ms"), + `${group.rssSampleCount ?? 0}/${group.cpuSampleCount ?? 0}`, group.commands.join(", "), ]), - ["Category", "Commands", "P50 wall", "P95 wall", "Max peak RSS", "CPU estimate", "Command IDs"], + ["Category", "Commands", "P50 wall", "P95 wall", "Max peak RSS", "CPU estimate", "RSS/CPU samples", "Command IDs"], ), ].join("\n"); } @@ -189,8 +198,15 @@ function summarizeProfile(commands) { const maxRssDeltaMb = Math.max(0, ...commands.map((command) => command.rssDeltaMb.max)); const maxCpuMsEstimate = Math.max(0, ...commands.map((command) => command.cpuMsEstimate.max)); const maxHarnessHeapDeltaMb = Math.max(0, ...commands.map((command) => command.harnessHeapDeltaMb.max)); + const totalSampleCount = sampleCount(commands); + const totalRssSampleCount = rssSampleCount(commands); + const totalCpuSampleCount = cpuSampleCount(commands); return { commandCount: commands.length, + sampleCount: totalSampleCount, + rssSampleCount: totalRssSampleCount, + cpuSampleCount: totalCpuSampleCount, + wallTimeBasis: "command-median-p95", p50WallMs: percentile(wallTimes, 0.5), p95WallMs: percentile(wallTimes, 0.95), maxPeakRssMb, @@ -206,6 +222,12 @@ function summarizeCommand(command, samples) { const rssDeltaMb = samples.map((sample) => sample.rssDeltaMb).sort((left, right) => left - right); const peakCpuPercent = samples.map((sample) => sample.peakCpuPercent).sort((left, right) => left - right); const cpuMsEstimate = samples.map((sample) => sample.cpuMsEstimate).sort((left, right) => left - right); + const statSampleCount = samples.reduce((sum, sample) => sum + (sample.statSampleCount ?? 0), 0); + const rssSampleTotal = samples.reduce( + (sum, sample) => sum + (sample.rssSampleCount ?? (sample.peakRssMb > 0 ? 1 : 0)), + 0, + ); + const cpuSampleTotal = samples.reduce((sum, sample) => sum + (sample.cpuSampleCount ?? 0), 0); const harnessHeapDeltaMb = samples .map((sample) => sample.harnessHeapDeltaMb) .sort((left, right) => left - right); @@ -222,6 +244,9 @@ function summarizeCommand(command, samples) { peakCpuPercent: summarizeNumbers(peakCpuPercent), cpuMsEstimate: summarizeNumbers(cpuMsEstimate), harnessHeapDeltaMb: summarizeNumbers(harnessHeapDeltaMb), + statSampleCount, + rssSampleCount: rssSampleTotal, + cpuSampleCount: cpuSampleTotal, exitCodes: [...new Set(samples.map((sample) => sample.exitCode))].sort(), }; } @@ -244,6 +269,8 @@ function summarizeCommandGroups(commands) { const cpuMs = categoryCommands .flatMap((command) => command.samples.map((sample) => sample.cpuMsEstimate)) .sort((left, right) => left - right); + const groupRssSampleCount = rssSampleCount(categoryCommands); + const groupCpuSampleCount = cpuSampleCount(categoryCommands); return { category, commandCount: categoryCommands.length, @@ -251,11 +278,36 @@ function summarizeCommandGroups(commands) { p95WallMs: percentile(wallTimes, 0.95), maxPeakRssMb: peakRss.at(-1) ?? 0, maxCpuMsEstimate: cpuMs.at(-1) ?? 0, + rssSampleCount: groupRssSampleCount, + cpuSampleCount: groupCpuSampleCount, commands: categoryCommands.map((command) => command.id), }; }); } +function hasRssSample(command) { + return (command.rssSampleCount ?? (command.peakRssMb?.max > 0 ? 1 : 0)) > 0; +} + +function sampleCount(commands) { + return commands.reduce((sum, command) => sum + (command.samples?.length ?? 0), 0); +} + +function rssSampleCount(commands) { + return commands.reduce((sum, command) => sum + (command.rssSampleCount ?? (command.peakRssMb?.max > 0 ? 1 : 0)), 0); +} + +function cpuSampleCount(commands) { + return commands.reduce((sum, command) => sum + (command.cpuSampleCount ?? 0), 0); +} + +function formatSampledMetric(value, count, unit = "MB") { + if ((count ?? 0) <= 0) { + return "n/a"; + } + return `${value} ${unit}`; +} + function summarizeNumbers(values) { return { min: values[0], diff --git a/test/ci-summary.test.js b/test/ci-summary.test.js index f69d324..4bd84c7 100644 --- a/test/ci-summary.test.js +++ b/test/ci-summary.test.js @@ -97,6 +97,8 @@ test("ci summary rolls up compatibility, policy, ref diff, and profile findings" p95WallMs: 75, maxPeakRssMb: 40, maxCpuMsEstimate: 30, + rssSampleCount: 2, + cpuSampleCount: 2, }, }, },