Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,16 @@ import { deriveReason } from "../../../utils/react-profiler/pipeline/utils";
import { readCommitTree } from "../../../utils/react-profiler/debug/dump";

const timeRangeSchema = z.object({
start: z.coerce.number().describe("Start of range in ms (performance.now clock)"),
end: z.coerce.number().describe("End of range in ms (performance.now clock)"),
start: z.coerce
.number()
.describe(
"Start of range in ms since profiling started — the same clock profiler-commit-query prints"
),
end: z.coerce
.number()
.describe(
"End of range in ms since profiling started — the same clock profiler-commit-query prints"
),
});

const zodSchema = z.object({
Expand Down
115 changes: 99 additions & 16 deletions packages/tool-server/src/tools/profiler/query/profiler-cpu-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,22 @@ import {
isArgentProfilerFunction,
type CpuSampleIndex,
} from "../../../utils/react-profiler/pipeline/00-cpu-correlate";
import type { CpuWindowResult } from "../../../utils/react-profiler/pipeline/00-cpu-correlate";
import type { HermesProfileNode } from "../../../utils/react-profiler/types/input";
import { readCpuProfile, readCommitTree } from "../../../utils/react-profiler/debug/dump";
import { promises as fs } from "fs";

const timeWindowSchema = z.object({
start: z.coerce.number().describe("Start of window in ms (performance.now clock)"),
end: z.coerce.number().describe("End of window in ms (performance.now clock)"),
start: z.coerce
.number()
.describe(
"Start of window in ms since profiling started — the same clock profiler-commit-query prints"
),
end: z.coerce
.number()
.describe(
"End of window in ms since profiling started — the same clock profiler-commit-query prints"
),
});

const zodSchema = z.object({
Expand All @@ -36,7 +45,9 @@ const zodSchema = z.object({
),
time_window_ms: timeWindowSchema
.optional()
.describe("Time window filter for time_window mode (ms, performance.now clock)"),
.describe(
"Time window filter for time_window mode (ms since profiling started — the same clock profiler-commit-query prints)"
),
component_name: z.string().optional().describe("Component name for component_cpu mode"),
function_name: z.string().optional().describe("Function name for call_tree mode"),
top_n: z.coerce
Expand Down Expand Up @@ -93,14 +104,85 @@ async function getIndex(sessionPaths: ProfilerSessionPaths): Promise<{
// Slow path: build index from raw CPU profile
const cpuProfile = await readCpuProfile(sessionPaths.cpuProfilePath);
let commitTree = null;
let firstCommitTs: number | null = null;
if (sessionPaths.commitsPath) {
const onDisk = await readCommitTree(sessionPaths.commitsPath);
commitTree = { commits: onDisk.commits };
firstCommitTs = onDisk.commits[0]?.timestamp ?? null;
}

return { index: buildCpuSampleIndex(cpuProfile, firstCommitTs), commitTree };
return { index: buildCpuSampleIndex(cpuProfile), commitTree };
}

/**
* Explain a window that produced no ranked functions.
*
* "No CPU hotspots found" was indistinguishable from "this commit was cheap",
* which is what made the documented drill-down a dead end (#619). Each way of
* finding nothing has a different meaning and a different next step, so each
* says so — in particular a window that IS covered by samples but contains only
* idle frames, which on real Hermes data is the common case (99% of samples in
* the reported session were idle).
*/
function explainEmptyWindow(res: CpuWindowResult, startMs: number, endMs: number): string {
const range = `${res.sampleRangeMs.start.toFixed(1)}–${res.sampleRangeMs.end.toFixed(1)}ms`;
const window = `${startMs.toFixed(1)}–${endMs.toFixed(1)}ms`;

if (res.sampleRangeMs.end === 0 && res.samplesInWindow === 0) {
return (
"_The CPU profile contains no samples. Sampling produced no data for this session — " +
"that is a capture failure, not a measurement of idleness._"
);
}

if (endMs < res.sampleRangeMs.start || startMs > res.sampleRangeMs.end) {
return (
`_No CPU samples exist in ${window} — that is outside the recorded sample range ` +
`(${range}). This is a coverage gap, not a measurement: nothing can be concluded about CPU ` +
`cost here. Sample times are ms since profiling started, the same clock ` +
"`profiler-commit-query` prints._"
);
}

if (res.samplesInWindow > 0) {
// Covered, but nothing was running: the honest and useful answer.
return (
`_${res.samplesInWindow} sample(s) covering ${res.coveredMs.toFixed(1)}ms fell inside ` +
`${window}, and all of them were idle — the JS thread was not executing during this window. ` +
"Native or UI-thread work would not appear here; use `native-profiler-start` for that._"
);
}

return (
`_No CPU samples fell inside ${window} (${(endMs - startMs).toFixed(1)}ms wide), although it ` +
`lies within the recorded range (${range}). The sampler runs roughly every ` +
`${res.medianIntervalMs > 0 ? res.medianIntervalMs.toFixed(1) : "13"}ms, so a window this ` +
"narrow can contain none at all. **Absence of samples is not evidence that this commit was " +
"cheap.** Widen the window, or use `mode=component_cpu`._"
);
}

/** Coverage line: what the numbers below are actually a measurement of. */
function coverageNote(res: CpuWindowResult, startMs: number, endMs: number): string {
const widthMs = endMs - startMs;
const lines = [
`**Window:** ${startMs.toFixed(1)}ms → ${endMs.toFixed(1)}ms (${widthMs.toFixed(1)}ms)`,
`**Samples:** ${res.samplesInWindow} covering ${res.coveredMs.toFixed(1)}ms` +
(res.idleMs > 0 ? `, of which ${res.idleMs.toFixed(1)}ms idle` : "") +
` — sampling interval ~${res.medianIntervalMs.toFixed(1)}ms. Self-times sum to sampled` +
" coverage, not to the window width.",
];
if (widthMs > 0 && widthMs < 3 * res.medianIntervalMs) {
lines.push(
`> This window is narrower than ~3 sampling intervals, so every figure carries ±1 sample` +
` (≈${res.medianIntervalMs.toFixed(1)}ms).`
);
}
if (res.maxIntervalMs > 50 && res.maxIntervalMs > 5 * res.medianIntervalMs) {
lines.push(
`> The sampler stalled for ${res.maxIntervalMs.toFixed(1)}ms inside this window; that whole` +
" gap is attributed to whichever function was caught by the sample that ended it."
);
}
return lines.join("\n\n");
}

function renderTopFunctions(
Expand All @@ -109,25 +191,22 @@ function renderTopFunctions(
startMs?: number,
endMs?: number
): string {
const windowStart = startMs ?? index.timestampsMs[0]!;
const windowEnd = endMs ?? index.timestampsMs[index.timestampsMs.length - 1]!;
const hotspots = queryCpuWindow(index, windowStart, windowEnd, topN);
const windowStart = startMs ?? index.intervalStartsMs[0] ?? 0;
const windowEnd = endMs ?? index.timestampsMs[index.timestampsMs.length - 1] ?? 0;
const res = queryCpuWindow(index, windowStart, windowEnd, topN);

if (hotspots.length === 0) return "_No CPU hotspots found in the specified range._";
if (res.hotspots.length === 0) return explainEmptyWindow(res, windowStart, windowEnd);

const header = "| Function | Self (ms) | Total (ms) | Location |";
const sep = "|---|---|---|---|";
const rows = hotspots.map((hs) => {
const rows = res.hotspots.map((hs) => {
const loc = hs.url
? `${shortenUrl(hs.url)}${hs.lineNumber != null ? `:${hs.lineNumber}` : ""}`
: "—";
return `| \`${hs.name}\` | ${hs.selfMs} | ${hs.totalMs} | ${loc} |`;
});

const rangeNote =
startMs != null ? `**Window:** ${startMs.toFixed(1)}ms → ${endMs!.toFixed(1)}ms\n\n` : "";

return `## CPU Hotspots\n\n${rangeNote}${header}\n${sep}\n${rows.join("\n")}`;
return `## CPU Hotspots\n\n${coverageNote(res, windowStart, windowEnd)}\n\n${header}\n${sep}\n${rows.join("\n")}`;
}

function renderCallTree(
Expand Down Expand Up @@ -293,7 +372,7 @@ function renderComponentCpu(
>();

for (const window of commitWindows.values()) {
const hotspots = queryCpuWindow(index, window.start, window.end, 50);
const { hotspots } = queryCpuWindow(index, window.start, window.end, 50);
for (const hs of hotspots) {
const existing = aggregated.get(hs.name);
if (existing) {
Expand Down Expand Up @@ -354,6 +433,10 @@ Requires react-profiler-stop (and ideally react-profiler-analyze) to have been c
Modes:
- top_functions: Global CPU hotspots ranked by self-time. Optional time_window_ms to filter.
- time_window: CPU breakdown for a specific time range (e.g. during a slow commit or hang).

Self-times are the summed sampling intervals of the samples that landed in the window, so they
measure sampled coverage rather than the window's width and do not change if you widen the query.
Every table states how many samples it covers and how much of that was idle.
- call_tree: For a given function_name, show its callees and optionally callers.
- component_cpu: For a given component_name, aggregate CPU activity across all its commits.
Use when investigating JS CPU hotspots or correlating CPU cost with specific components.
Expand Down
Loading