Files
oh-my-pi/packages/coding-agent/src/utils/sample-profile.ts
T
can1357 222e3569bd perf: optimized markdown rendering speed and device doc schemas
- Render device doc parameter schemas as TypeScript types instead of raw JSON schema dumps.
- Optimize marked streaming block rules and add pre-gates to reduce CPU overhead.
- Increase the markdown render cache entry budget from 32 KiB to 256 KiB.
2026-07-27 22:19:51 +02:00

438 lines
15 KiB
TypeScript

/**
* Parser and renderer for macOS `/usr/bin/sample` call-tree reports
* (conventionally saved as `*.sample.txt`).
*
* The raw report is a 10k+ line ASCII call tree with mangled symbols —
* expensive for an agent to digest. `renderSampleProfile` converts it into a
* compact bottleneck summary:
*
* - per-thread hot paths, pruned to frames with meaningful on-CPU samples,
* with pass-through chains collapsed and direct recursion flattened
* - blocked/idle threads reduced to a one-line classification
* - a process-wide "top functions by self samples" table
* - Rust v0 and legacy symbols demangled (best-effort, path extraction)
*
* Consumed by the read tool: `*.sample.txt` reads show the summary, `:raw`
* returns the original bytes.
*/
import { formatPct, mergeInto, type ProfileNode, type RenderTreeContext, renderProfileNode } from "./profile-tree";
/** Matches paths the read tool should treat as macOS sample reports. */
export function isSampleProfilePath(filePath: string): boolean {
return /\.sample\.txt$/i.test(filePath);
}
/** Leaf symbols that mean "thread is parked in the kernel", not burning CPU. */
const WAIT_SYMBOLS: Record<string, true> = {
__accept: true,
__psynch_cvwait: true,
__psynch_mutexwait: true,
__psynch_rw_rdlock: true,
__psynch_rw_wrlock: true,
__recvfrom: true,
__select: true,
__semwait_signal: true,
__sigwait: true,
__ulock_wait: true,
__ulock_wait2: true,
__wait4: true,
__workq_kernreturn: true,
kevent: true,
kevent64: true,
kevent_qos: true,
mach_msg2_trap: true,
mach_msg_trap: true,
poll: true,
semaphore_timedwait_trap: true,
semaphore_wait_trap: true,
start_wqthread: true,
swtch_pri: true,
thread_suspend: true,
usleep: true,
};
/** One frame in the sampled call tree. Counts are sample hits (subtree total). */
export interface SampleFrame {
count: number;
symbol: string;
module?: string;
children: SampleFrame[];
}
/** One sampled thread: `Thread_<id>` root plus its call tree. */
export interface SampleThread {
id: string;
name?: string;
total: number;
roots: SampleFrame[];
}
/** Metadata from the report preamble (everything before `Call graph:`). */
export interface SampleProfileHeader {
process: string;
pid: number;
intervalMs: number;
path?: string;
codeType?: string;
osVersion?: string;
footprint?: string;
footprintPeak?: string;
}
/** Parsed macOS sample report. */
export interface SampleProfile {
header: SampleProfileHeader;
threads: SampleThread[];
}
const ANALYSIS_RE = /^Analysis of sampling (.+?) \(pid (\d+)\) every (\d+) milliseconds?/;
const THREAD_RE = /^Thread_([^\s:]+):?\s*(.*)$/;
/**
* Parse a macOS `sample` report. Returns null when the text does not look
* like sample output (missing analysis preamble or `Call graph:` section).
*/
export function parseSampleProfile(text: string): SampleProfile | null {
const lines = text.split("\n");
const analysis = ANALYSIS_RE.exec(lines[0] ?? "");
if (!analysis) return null;
const callGraphIx = lines.indexOf("Call graph:");
if (callGraphIx === -1) return null;
const header: SampleProfileHeader = {
process: analysis[1],
pid: Number(analysis[2]),
intervalMs: Number(analysis[3]),
};
for (const line of lines.slice(1, callGraphIx)) {
const kv = /^([A-Za-z/ ()]+?):\s+(.*)$/.exec(line);
if (!kv) continue;
const value = kv[2].trim();
switch (kv[1]) {
case "Path":
header.path = value;
break;
case "Code Type":
header.codeType = value;
break;
case "OS Version":
header.osVersion = value;
break;
case "Physical footprint":
header.footprint = value;
break;
case "Physical footprint (peak)":
header.footprintPeak = value;
break;
}
}
const threads: SampleThread[] = [];
let thread: SampleThread | undefined;
// Stack of (depth, frame) for the current thread; depth 1 = thread root frame.
const stack: Array<{ depth: number; frame: SampleFrame }> = [];
for (let ix = callGraphIx + 1; ix < lines.length; ix++) {
const line = lines[ix];
if (/^(Total number in stack|Sort by top of stack|Binary Images:)/.test(line)) break;
if (!line.startsWith(" ")) continue;
const body = line.slice(4);
// Decorators are one char + one space per tree level (`+ ! : | `), so the
// sample count starts at an even offset; scan pairs until the first digit.
let p = 0;
while (p < body.length && (body[p] < "0" || body[p] > "9")) p += 2;
if (p >= body.length) continue;
const depth = p / 2;
const rest = /^(\d+)\s+(.*)$/.exec(body.slice(p));
if (!rest) continue;
const count = Number(rest[1]);
const frameText = rest[2];
if (depth === 0) {
const tm = THREAD_RE.exec(frameText);
if (!tm) continue;
const name = tm[2].trim().replace(/\s+/g, " ");
thread = { id: tm[1], name: name || undefined, total: count, roots: [] };
threads.push(thread);
stack.length = 0;
continue;
}
if (!thread) continue;
const frame: SampleFrame = { count, ...parseFrameText(frameText), children: [] };
while (stack.length > 0 && stack[stack.length - 1].depth >= depth) stack.pop();
const siblings = stack.length > 0 ? stack[stack.length - 1].frame.children : thread.roots;
siblings.push(frame);
stack.push({ depth, frame });
}
if (threads.length === 0) return null;
return { header, threads };
}
/** Split `symbol (in module) + offsets [addrs]` into symbol + module. */
function parseFrameText(text: string): { symbol: string; module?: string } {
let s = text;
const addrIx = s.lastIndexOf(" [");
if (addrIx !== -1 && s.endsWith("]")) s = s.slice(0, addrIx);
s = s.replace(/ \+ [0-9][0-9,.]*$/, "");
const modIx = s.lastIndexOf(" (in ");
if (modIx !== -1 && s.endsWith(")")) {
return { symbol: s.slice(0, modIx).trim(), module: s.slice(modIx + 6, -1) };
}
return { symbol: s.trim() };
}
// ---------------------------------------------------------------------------
// Demangling
// ---------------------------------------------------------------------------
const LEGACY_ESCAPES: ReadonlyArray<[string, string]> = [
["$LT$", "<"],
["$GT$", ">"],
["$RF$", "&"],
["$BP$", "*"],
["$C$", ","],
["$u20$", " "],
["$u27$", "'"],
["$u7b$", "{"],
["$u7d$", "}"],
["..", "::"],
];
const IDENT_RE = /^[A-Za-z0-9_.$]+$/;
const LEGACY_HASH_RE = /^h[0-9a-f]{16}$/;
/**
* Best-effort demangle of Rust symbols (v0 `_R…` and legacy `_ZN…E`).
* For v0 this is a path extractor, not a full demangler: identifiers are
* pulled out in order and joined with `::`, so generic arguments appear as
* extra path segments. Non-Rust symbols pass through unchanged.
*/
export function demangleSymbol(raw: string): string {
if (raw.startsWith("_R")) return demangleV0(raw) ?? raw;
const legacy = /^_?_ZN(.*)$/.exec(raw);
if (legacy) return demangleLegacy(legacy[1]) ?? raw;
return raw;
}
function demangleV0(raw: string): string | null {
const s = raw.slice(2);
const parts: string[] = [];
let i = 0;
while (i < s.length) {
const ch = s[i];
if (ch >= "1" && ch <= "9") {
let j = i;
while (j < s.length && s[j] >= "0" && s[j] <= "9") j++;
const len = Number(s.slice(i, j));
// v0 inserts a `_` separator when the identifier starts with a digit or `_`.
let k = j;
if (s[k] === "_") k++;
const ident = s.slice(k, k + len);
if (ident.length === len && IDENT_RE.test(ident)) {
parts.push(ident);
i = k + len;
} else {
i = j;
}
} else if (ch === "s" || ch === "B") {
// Disambiguator (`s<base62>_`) or backref (`B<base62>_`): skip.
const m = /^[sB][0-9a-zA-Z]*_/.exec(s.slice(i));
i += m ? m[0].length : 1;
} else {
i++;
}
}
return parts.length > 0 ? parts.join("::") : null;
}
function demangleLegacy(s: string): string | null {
const parts: string[] = [];
let i = 0;
while (i < s.length && s[i] >= "0" && s[i] <= "9") {
let j = i;
while (j < s.length && s[j] >= "0" && s[j] <= "9") j++;
const len = Number(s.slice(i, j));
const ident = s.slice(j, j + len);
if (ident.length !== len) break;
if (!LEGACY_HASH_RE.test(ident)) parts.push(unescapeLegacy(ident));
i = j + len;
}
return parts.length > 0 ? parts.join("::") : null;
}
function unescapeLegacy(ident: string): string {
let out = ident;
for (const [from, to] of LEGACY_ESCAPES) out = out.replaceAll(from, to);
return out;
}
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
/** Fraction of a thread's on-CPU samples a subtree needs to stay visible. */
const PRUNE_FRACTION = 0.02;
/** Threads with fewer on-CPU samples than this share of their total are "idle". */
const IDLE_FRACTION = 0.002;
const IDLE_MIN_SAMPLES = 10;
const TOP_FUNCTIONS = 20;
function selfOf(frame: SampleFrame): number {
let children = 0;
for (const child of frame.children) children += child.count;
return Math.max(0, frame.count - children);
}
/**
* Build a display node for a frame subtree: demangled label, on-CPU value
* (self samples of non-wait frames), same-symbol siblings merged (`sample`
* splits them by call-site offset, which only fragments hot totals).
*/
function buildProfileNode(frame: SampleFrame, demangleCache: Map<string, string>, mainModule: string): ProfileNode {
let symbol = demangleCache.get(frame.symbol);
if (symbol === undefined) {
symbol = demangleSymbol(frame.symbol);
demangleCache.set(frame.symbol, symbol);
}
const children: ProfileNode[] = [];
for (const rawChild of frame.children) {
const child = buildProfileNode(rawChild, demangleCache, mainModule);
const existing = children.find(c => c.key === child.key);
if (existing) mergeInto(existing, child);
else children.push(child);
}
let cpu = WAIT_SYMBOLS[frame.symbol] ? 0 : selfOf(frame);
for (const child of children) cpu += child.value;
const label = frame.module && frame.module !== mainModule ? `${symbol} (${frame.module})` : symbol;
return { key: symbol, label, value: cpu, recursion: 0, children };
}
/** Aggregate on-CPU self samples per demangled symbol across all threads. */
function aggregateSelf(
profile: SampleProfile,
demangleCache: Map<string, string>,
): Map<string, { cpu: number; module?: string }> {
const totals = new Map<string, { cpu: number; module?: string }>();
const visit = (frame: SampleFrame): void => {
if (!WAIT_SYMBOLS[frame.symbol]) {
const self = selfOf(frame);
if (self > 0) {
let symbol = demangleCache.get(frame.symbol);
if (symbol === undefined) {
symbol = demangleSymbol(frame.symbol);
demangleCache.set(frame.symbol, symbol);
}
const entry = totals.get(symbol);
if (entry) entry.cpu += self;
else totals.set(symbol, { cpu: self, module: frame.module });
}
}
for (const child of frame.children) visit(child);
};
for (const thread of profile.threads) for (const root of thread.roots) visit(root);
return totals;
}
/** Dominant wait leaf of an idle thread ("what is it blocked on"). */
function dominantWait(thread: SampleThread): string | undefined {
let best: { symbol: string; count: number } | undefined;
const visit = (frame: SampleFrame): void => {
if (frame.children.length === 0 && WAIT_SYMBOLS[frame.symbol]) {
if (!best || frame.count > best.count) best = { symbol: frame.symbol, count: frame.count };
}
for (const child of frame.children) visit(child);
};
for (const root of thread.roots) visit(root);
return best?.symbol;
}
/**
* Render a macOS sample report as an agent-friendly bottleneck summary.
* Returns null when `text` is not sample output (caller falls back to the
* plain-text path).
*/
export function renderSampleProfile(text: string): string | null {
const profile = parseSampleProfile(text);
if (!profile) return null;
const { header, threads } = profile;
const demangleCache = new Map<string, string>();
const annotated = threads.map(thread => {
const roots = thread.roots.map(root => buildProfileNode(root, demangleCache, header.process));
let cpu = 0;
for (const root of roots) cpu += root.value;
return { thread, roots, cpu };
});
const processCpu = annotated.reduce((sum, t) => sum + t.cpu, 0);
const maxSamples = threads.reduce((max, t) => Math.max(max, t.total), 0);
const out: string[] = [];
out.push(`macOS sample profile: ${header.process} (pid ${header.pid}), sampled every ${header.intervalMs} ms`);
const meta: string[] = [];
if (header.path) meta.push(header.path);
if (header.codeType) meta.push(header.codeType);
if (header.osVersion) meta.push(`macOS ${header.osVersion.replace(/^macOS\s+/, "")}`);
if (meta.length > 0) out.push(meta.join(" | "));
const durationSec = (maxSamples * header.intervalMs) / 1000;
let statLine = `Duration: ~${durationSec.toFixed(1)} s (${maxSamples} samples/thread)`;
if (header.footprint) {
statLine += ` | Footprint: ${header.footprint}${header.footprintPeak ? ` (peak ${header.footprintPeak})` : ""}`;
}
out.push(statLine);
out.push("");
out.push(
`Process total: ${processCpu} on-CPU samples across ${threads.length} threads. Counts and percentages below are on-CPU samples (blocked time excluded).`,
);
const active: typeof annotated = [];
const idle: typeof annotated = [];
for (const entry of annotated) {
const threshold = Math.max(IDLE_MIN_SAMPLES, entry.thread.total * IDLE_FRACTION);
(entry.cpu >= threshold ? active : idle).push(entry);
}
active.sort((a, b) => b.cpu - a.cpu);
for (const { thread, roots, cpu } of active) {
out.push("");
const title = thread.name ? `${thread.name} (Thread_${thread.id})` : `Thread_${thread.id}`;
out.push(`## ${title} — ${thread.total} samples, ${cpu} on-CPU (${formatPct(cpu, thread.total)})`);
const ctx: RenderTreeContext = {
out,
total: cpu,
minValue: Math.max(3, Math.round(cpu * PRUNE_FRACTION)),
formatValue: String,
valueWidth: 6,
};
const kept = roots.filter(root => root.value >= ctx.minValue).sort((a, b) => b.value - a.value);
for (const root of kept) renderProfileNode(root, 0, ctx);
if (kept.length === 0) out.push(` (no call path above ${ctx.minValue} on-CPU samples)`);
}
if (idle.length > 0) {
out.push("");
out.push(`## Idle / negligible threads (${idle.length})`);
for (const { thread, cpu } of idle) {
const title = thread.name ? `${thread.name} (Thread_${thread.id})` : `Thread_${thread.id}`;
const wait = dominantWait(thread);
const state = wait ? `blocked in ${wait} (${cpu} on-CPU)` : `${cpu}/${thread.total} samples on-CPU`;
out.push(`- ${title}: ${state}`);
}
}
const totals = [...aggregateSelf(profile, demangleCache).entries()].sort((a, b) => b[1].cpu - a[1].cpu);
if (totals.length > 0) {
out.push("");
out.push("## Top functions by self samples (process-wide, blocked time excluded)");
for (const [symbol, { cpu, module }] of totals.slice(0, TOP_FUNCTIONS)) {
const suffix = module && module !== header.process ? ` (${module})` : "";
out.push(`${String(cpu).padStart(6)} ${formatPct(cpu, processCpu).padStart(6)} ${symbol}${suffix}`);
}
}
out.push("");
out.push("[Summarized view of a macOS `sample` call-tree report. Use ':raw' to read the original file.]");
return out.join("\n");
}