diff --git a/examples/SampleApp/metro.config.no-dev.js b/examples/SampleApp/metro.config.no-dev.js new file mode 100644 index 0000000000..aaeff9b92b --- /dev/null +++ b/examples/SampleApp/metro.config.no-dev.js @@ -0,0 +1,43 @@ +/** + * Metro config that forces a `dev=false` bundle for performance profiling. + * + * Use this to measure scroll/render perf WITHOUT React's dev-mode wrappers + * (`runWithFiberInDEV`, `getComponentStack`, etc — they account for ~22% + * of a captured profile and don't exist in release builds). Bundle is still + * unminified so function names stay readable in the .cpuprofile. + * + * Usage: + * yarn workspace sampleapp start --config metro.config.no-dev.js --reset-cache + * + * Then reload the app (shake → Reload, or `r` in Metro). The next bundle + * fetch will be served with dev=false regardless of what the app asks for. + * Run `node perf/capture-hermes-profile.js` as usual. To restore normal + * dev mode just stop Metro and start it again without `--config`. + * + * NOTE: this only changes the served JS bundle. The native binary is still + * a debug build; native code paths (Yoga, layout, view creation, image + * decoding) remain debug-instrumented. To benchmark a true release native + * pipeline you'd need to build a release variant of the app itself. + */ +const baseConfig = require('./metro.config.js'); + +module.exports = { + ...baseConfig, + server: { + ...(baseConfig.server || {}), + enhanceMiddleware: (middleware, metroServer) => { + const wrapped = + baseConfig.server && typeof baseConfig.server.enhanceMiddleware === 'function' + ? baseConfig.server.enhanceMiddleware(middleware, metroServer) + : middleware; + return (req, res, next) => { + if (req.url && req.url.includes('dev=true')) { + req.url = req.url.replace(/([?&])dev=true/g, '$1dev=false'); + // Print once-per-request so it's obvious what's happening. + process.stdout.write(`[no-dev] rewrote bundle URL to: ${req.url}\n`); + } + return wrapped(req, res, next); + }; + }, + }, +}; diff --git a/package/src/components/Message/MessageItemView/MessageBubble.tsx b/package/src/components/Message/MessageItemView/MessageBubble.tsx index 3277850cfc..5fa759b494 100644 --- a/package/src/components/Message/MessageItemView/MessageBubble.tsx +++ b/package/src/components/Message/MessageItemView/MessageBubble.tsx @@ -27,7 +27,9 @@ type SwipableMessageWrapperProps = Pick< onSwipe: () => void; }; -export const SwipableMessageWrapper = React.memo((props: SwipableMessageWrapperProps) => { +export const SwipableMessageWrapper = React.memo(function SwipableMessageWrapper( + props: SwipableMessageWrapperProps, +) { const { children, messageSwipeToReplyHitSlop, onSwipe } = props; const { MessageSwipeContent } = useComponentsContext(); const isRTL = I18nManager.isRTL; diff --git a/package/src/components/Message/MessageItemView/MessageContent.tsx b/package/src/components/Message/MessageItemView/MessageContent.tsx index d81ef24253..ce29f28020 100644 --- a/package/src/components/Message/MessageItemView/MessageContent.tsx +++ b/package/src/components/Message/MessageItemView/MessageContent.tsx @@ -1,5 +1,5 @@ import React, { useMemo } from 'react'; -import { AnimatableNumericValue, ColorValue, Pressable, StyleSheet, View } from 'react-native'; +import { ColorValue, Pressable, StyleSheet, View, ViewStyle } from 'react-native'; import { MessageTextContainer } from './MessageTextContainer'; @@ -169,47 +169,46 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => { [message, isMessageAIGenerated], ); - const getBorderRadius = () => { + // Merged background-color + border-radius object passed directly into the + // bubble's style array (no spread at the call site). Theme-defined radii + // override the group-position-computed defaults; theme-undefined radii are + // omitted so they don't override the computed defaults. + const bubbleColorAndRadius = useMemo(() => { // enum('top', 'middle', 'bottom', 'single') const groupPosition = groupStyles?.[0]; - const isBottomOrSingle = groupPosition === 'single' || groupPosition === 'bottom'; - let borderBottomLeftRadius = components.messageBubbleRadiusGroupBottom; - let borderBottomRightRadius = components.messageBubbleRadiusGroupBottom; + let computedBottomLeftRadius = components.messageBubbleRadiusGroupBottom; + let computedBottomRightRadius = components.messageBubbleRadiusGroupBottom; if (isBottomOrSingle) { - // add relevant sharp corner + // add relevant sharp corner (the "tail") if (isMyMessage) { - borderBottomRightRadius = components.messageBubbleRadiusTail; + computedBottomRightRadius = components.messageBubbleRadiusTail; } else { - borderBottomLeftRadius = components.messageBubbleRadiusTail; + computedBottomLeftRadius = components.messageBubbleRadiusTail; } } - return { - borderBottomLeftRadius, - borderBottomRightRadius, - }; - }; - - const getBorderRadiusFromTheme = () => { - const bordersFromTheme: Record = { - borderBottomLeftRadius, - borderBottomRightRadius, - borderRadius, - borderTopLeftRadius, - borderTopRightRadius, + const style: ViewStyle = { + backgroundColor, + borderBottomLeftRadius: borderBottomLeftRadius ?? computedBottomLeftRadius, + borderBottomRightRadius: borderBottomRightRadius ?? computedBottomRightRadius, }; + if (borderRadius !== undefined) style.borderRadius = borderRadius; + if (borderTopLeftRadius !== undefined) style.borderTopLeftRadius = borderTopLeftRadius; + if (borderTopRightRadius !== undefined) style.borderTopRightRadius = borderTopRightRadius; - // filter out undefined values - for (const key in bordersFromTheme) { - if (bordersFromTheme[key] === undefined) { - delete bordersFromTheme[key]; - } - } - - return bordersFromTheme; - }; + return style; + }, [ + backgroundColor, + borderBottomLeftRadius, + borderBottomRightRadius, + borderRadius, + borderTopLeftRadius, + borderTopRightRadius, + groupStyles, + isMyMessage, + ]); const { setNativeScrollability } = useMessageListItemContext(); const hasContentSideViews = !!(MessageContentLeadingView || MessageContentTrailingView); @@ -357,12 +356,8 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => { { +export const MessageWrapper = React.memo(function MessageWrapper(props: MessageWrapperProps) { const { message, previousMessage, nextMessage } = props; const { client } = useChatContext(); const { diff --git a/package/src/components/Message/MessageItemView/utils/renderText.tsx b/package/src/components/Message/MessageItemView/utils/renderText.tsx index feb3b39f6f..9061cca175 100644 --- a/package/src/components/Message/MessageItemView/utils/renderText.tsx +++ b/package/src/components/Message/MessageItemView/utils/renderText.tsx @@ -101,6 +101,37 @@ const defaultMarkdownStyles: MarkdownStyle = { fontSize: primitives.typographyFontSizeMd, lineHeight: primitives.typographyLineHeightNormal, }, + // Heading sizes are derived from the body font size (`typographyFontSizeMd`) so they + // scale with the integrator's typography settings. lineHeight = fontSize × 1.25 to + // give headings room to breathe. Both fields are required here: without lineHeight, + // the inherited `lineHeight: typographyLineHeightNormal` (20) from `styles.text` (set + // in renderText below) leaks into the heading's inner Text via the markdown library's + // text rule (`{...styles.text, ...state.style}`) and squishes larger heading fontSizes + // into a 20px line box. + heading1: { + fontSize: primitives.typographyFontSizeMd * 2, + lineHeight: primitives.typographyFontSizeMd * 2 * 1.25, + }, + heading2: { + fontSize: primitives.typographyFontSizeMd * 1.5, + lineHeight: primitives.typographyFontSizeMd * 1.5 * 1.25, + }, + heading3: { + fontSize: primitives.typographyFontSizeMd * 1.25, + lineHeight: primitives.typographyFontSizeMd * 1.25 * 1.25, + }, + heading4: { + fontSize: primitives.typographyFontSizeMd, + lineHeight: primitives.typographyFontSizeMd * 1.25, + }, + heading5: { + fontSize: primitives.typographyFontSizeMd * 0.875, + lineHeight: primitives.typographyFontSizeMd * 0.875 * 1.25, + }, + heading6: { + fontSize: primitives.typographyFontSizeMd * 0.75, + lineHeight: primitives.typographyFontSizeMd * 0.75 * 1.25, + }, inlineCode: { padding: primitives.spacingXxs, paddingHorizontal: primitives.spacingXxs, diff --git a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.tsx.snap b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.tsx.snap index 3b4aa75c01..68d9536de2 100644 --- a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.tsx.snap +++ b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.tsx.snap @@ -511,7 +511,7 @@ exports[`Thread should match thread snapshot 1`] = ` "borderBottomLeftRadius": 0, "borderBottomRightRadius": 20, }, - {}, + null, {}, {}, ] @@ -843,7 +843,7 @@ exports[`Thread should match thread snapshot 1`] = ` "borderBottomLeftRadius": 0, "borderBottomRightRadius": 20, }, - {}, + null, {}, {}, ] @@ -1208,7 +1208,7 @@ exports[`Thread should match thread snapshot 1`] = ` "borderBottomLeftRadius": 0, "borderBottomRightRadius": 20, }, - {}, + null, {}, {}, ] @@ -1534,7 +1534,7 @@ exports[`Thread should match thread snapshot 1`] = ` "borderBottomLeftRadius": 0, "borderBottomRightRadius": 20, }, - {}, + null, {}, {}, ] diff --git a/perf/.gitignore b/perf/.gitignore new file mode 100644 index 0000000000..0427870a07 --- /dev/null +++ b/perf/.gitignore @@ -0,0 +1,2 @@ +profiles/ +*.cpuprofile diff --git a/perf/README.md b/perf/README.md new file mode 100644 index 0000000000..4ff80ef01a --- /dev/null +++ b/perf/README.md @@ -0,0 +1,75 @@ +# perf/ + +Profiling tooling for the SDK row-render perf initiative. + +## Capture a `.cpuprofile` + +Two options. + +### A) Via the helper script + +```sh +node perf/capture-hermes-profile.js +``` + +Connects to Metro's Hermes target, starts profiling, waits for Enter, writes a `.cpuprofile` into `perf/profiles/`, then auto-runs the analyzer. + +### B) Via Chrome DevTools + +1. Run SampleApp on a device. Make sure Metro is up. +2. Chromium → `chrome://inspect` → click **inspect** on the Hermes target. +3. DevTools → **Performance** tab → **Record** (Cmd+E). +4. Do your scenario (open a channel with 30+ messages; scroll to trigger renders). +5. **Stop**. +6. Right-click the recording → **Save profile…** → save into `perf/profiles/` (gitignored). + +A 10–15 second profile is plenty for analysis. + +## Analyze a single profile + +```sh +node perf/analyze-cpuprofile.js perf/profiles/baseline.cpuprofile +``` + +Outputs: + +- Profile summary (duration, sample count, sample rate). +- ⚠ warning if the profile looks un-desymbolicated. +- **Time by category** — auto-bucketed by source: `Idle`, `GC`, `npm: `, `SDK source`, `App source`, `builtin:Object`, `builtin:JSON`, `VM / native`. No hand-curated patterns. +- Time by **source file**. +- **Top functions by self time** (where the JS thread actually sits). +- **Top functions by total time** (which call sites dominate). + +Optional drilldown into specific functions: + +```sh +node perf/analyze-cpuprofile.js perf/profiles/x.cpuprofile \ + --inside MessageWithContext,useCreateMessageContext,renderText +``` + +## Desymbolicate (per-package buckets) + +Dev profiles collapse every frame into one Metro bundle URL, so categorization shows everything as `App source`. To recover per-package attribution, fetch Metro's source map and run the desymbolicator: + +```sh +curl -s 'http://localhost:8081/index.map?platform=ios&dev=true&minify=false' \ + -o /tmp/dev.map.json +node perf/desymbolicate-cpuprofile.js perf/profiles/x.cpuprofile /tmp/dev.map.json +node perf/analyze-cpuprofile.js perf/profiles/x.desymbolicated.cpuprofile +``` + +## Diff two profiles (before vs after a change) + +```sh +node perf/analyze-cpuprofile.js --diff perf/profiles/before.cpuprofile perf/profiles/after.cpuprofile +``` + +Per-category self-time delta + top function self-time deltas (sorted by `|delta|`). Optional `--grep ` to zoom in on specific function names. Warns if sample rates between the two profiles diverge >10%. + +For a fair diff, capture both profiles using the **same scenario** and the **same device** in roughly the same conditions. + +## Conventions + +- Keep captured `.cpuprofile` files in `perf/profiles/` (gitignored). +- Name files descriptively: `baseline.cpuprofile`, `step-8.cpuprofile`, etc. +- Profiles must be captured in dev mode (Metro) so function names are intact. Release builds are minified — desymbolicate with the matching source map if you need to analyze one. diff --git a/perf/analyze-cpuprofile.js b/perf/analyze-cpuprofile.js new file mode 100644 index 0000000000..c4bae21fd9 --- /dev/null +++ b/perf/analyze-cpuprofile.js @@ -0,0 +1,600 @@ +#!/usr/bin/env node +/** + * Analyze a Hermes / V8 sampling profile (.cpuprofile) and report what's + * clogging the JS thread. Also supports diffing two profiles. + * + * Usage: + * node perf/analyze-cpuprofile.js + * node perf/analyze-cpuprofile.js --diff + * + * The .cpuprofile format is the standard Chrome DevTools sampling profile + * (same JSON shape whether captured from V8 or Hermes). No native deps. + */ + +const fs = require('fs'); +const path = require('path'); + +// ---------- helpers ------------------------------------------------------ + +function microsToMs(us) { + return (us / 1000).toFixed(1); +} + +function pct(num, denom) { + if (!denom) return '0.00'; + return ((num / denom) * 100).toFixed(2); +} + +function pad(s, n, right = false) { + s = String(s); + if (s.length >= n) return s; + return right ? s + ' '.repeat(n - s.length) : ' '.repeat(n - s.length) + s; +} + +function nodeLabel(node) { + const cf = node.callFrame || {}; + const fn = cf.functionName || '(anonymous)'; + const url = cf.url || ''; + if (!url) return fn; + // Last 2 path segments + line number + const parts = url.split('/').filter(Boolean); + const filename = parts.slice(-2).join('/'); + const line = typeof cf.lineNumber === 'number' ? cf.lineNumber + 1 : '?'; + return `${fn} (${filename}:${line})`; +} + +function shortFile(node) { + const url = (node.callFrame && node.callFrame.url) || ''; + if (!url) return ''; + return url.split('/').slice(-2).join('/'); +} + +// ---------- profile loader / preprocessor -------------------------------- + +function loadProfile(filePath) { + if (!fs.existsSync(filePath)) { + console.error(`File not found: ${filePath}`); + process.exit(1); + } + let raw; + try { + raw = fs.readFileSync(filePath, 'utf8'); + } catch (err) { + console.error(`Failed to read ${filePath}: ${err.message}`); + process.exit(1); + } + let profile; + try { + profile = JSON.parse(raw); + } catch (err) { + console.error(`Not valid JSON: ${filePath} (${err.message})`); + process.exit(1); + } + // The file may be one of: + // 1) A V8 .cpuprofile object: { nodes, samples, timeDeltas, ... } + // 2) A Chrome trace event array (RN 0.81 Bridgeless `Tracing` domain): + // [{name:"Profile", args:{data:{startTime}}}, {name:"ProfileChunk", args:{data:{cpuProfile:{nodes,samples}, timeDeltas}}}, ...] + // Convert (2) to (1) before returning. + if (Array.isArray(profile)) { + profile = chromeTraceToV8Profile(profile, filePath); + } + if (!profile.nodes || !profile.samples) { + console.error(`File does not look like a .cpuprofile (missing nodes/samples): ${filePath}`); + process.exit(1); + } + return profile; +} + +// Convert a Chrome trace event array to a V8 .cpuprofile object. +// Hermes/RN Tracing nodes use `parent: `; V8 .cpuprofile uses `children: [...]`. +// We accumulate nodes/samples/timeDeltas across all ProfileChunk events and +// derive `children` from `parent` references. +function chromeTraceToV8Profile(events, filePath) { + const profileEvent = events.find((e) => e.name === 'Profile'); + const chunkEvents = events.filter((e) => e.name === 'ProfileChunk'); + if (!profileEvent && chunkEvents.length === 0) { + console.error(`Trace file has no Profile/ProfileChunk events: ${filePath}`); + process.exit(1); + } + const startTime = profileEvent?.args?.data?.startTime || 0; + const nodesById = new Map(); + const samples = []; + const timeDeltas = []; + for (const c of chunkEvents) { + const d = c.args?.data || {}; + const chunkNodes = d.cpuProfile?.nodes || []; + const chunkSamples = d.cpuProfile?.samples || []; + const chunkDeltas = d.timeDeltas || []; + for (const n of chunkNodes) { + if (!nodesById.has(n.id)) { + // Hermes callFrame uses scriptId as number; V8 expects string. Normalize. + const cf = { ...(n.callFrame || {}) }; + if (typeof cf.scriptId === 'number') cf.scriptId = String(cf.scriptId); + nodesById.set(n.id, { + id: n.id, + callFrame: cf, + parent: n.parent || null, + hitCount: 0, + children: [], + }); + } + } + for (const s of chunkSamples) samples.push(s); + for (const dt of chunkDeltas) timeDeltas.push(dt); + } + // Derive children lists from parent refs + for (const node of nodesById.values()) { + if (node.parent != null) { + const p = nodesById.get(node.parent); + if (p) p.children.push(node.id); + } + } + // Compute endTime from accumulated deltas + const totalUs = timeDeltas.reduce((a, b) => a + b, 0); + return { + nodes: [...nodesById.values()], + samples, + timeDeltas, + startTime, + endTime: startTime + totalUs, + }; +} + +function buildIndex(profile) { + const nodesById = new Map(); + for (const node of profile.nodes) { + nodesById.set(node.id, { + ...node, + parent: null, + selfTimeUs: 0, + totalTimeUs: 0, + }); + } + for (const node of nodesById.values()) { + if (!node.children) continue; + for (const childId of node.children) { + const child = nodesById.get(childId); + if (child) child.parent = node.id; + } + } + return nodesById; +} + +function computeSelfTimes(profile, nodesById) { + const samples = profile.samples; + const deltas = profile.timeDeltas || []; + for (let i = 0; i < samples.length; i++) { + const nodeId = samples[i]; + const delta = deltas[i] || 0; + const node = nodesById.get(nodeId); + if (node) node.selfTimeUs += delta; + } +} + +function computeTotalTimes(nodesById) { + // Memoized DFS — each tree is independent so no double-counting risk. + const memo = new Map(); + function totalOf(node) { + if (memo.has(node.id)) return memo.get(node.id); + let t = node.selfTimeUs; + if (node.children) { + for (const childId of node.children) { + const child = nodesById.get(childId); + if (child) t += totalOf(child); + } + } + memo.set(node.id, t); + return t; + } + for (const node of nodesById.values()) { + node.totalTimeUs = totalOf(node); + } +} + +// ---------- categorization ------------------------------------------------ +// +// Generic bucketing: follow the URL, don't hand-curate. The point is to see +// where the JS thread actually goes, not to confirm a hypothesis. +// +// Buckets emitted: +// Idle — (root)/(program)/(idle) pseudo-frames +// GC — garbage collector frames +// builtin: — Hermes/V8 builtins with no URL +// (e.g. builtin:Object covers Object.assign, +// builtin:JSON covers JSON.stringify/parse) +// VM / native — other URL-less frames +// npm: — anything resolved under node_modules/ +// (scoped packages keep their @scope/name) +// SDK source — package/src/** or stream-chat-react-native/(src|lib) +// App source — anything else with a URL (consumer app code) + +const NODE_MODULES_PKG_RE = /node_modules\/(@[^/]+\/[^/]+|[^/]+)/; +const SDK_SOURCE_RE = /(?:stream-chat-react-native\/(?:package\/)?(?:src|lib)|\/package\/src)\//; +const BUILTIN_RE = /^([A-Z][A-Za-z0-9]*)\.(?:prototype\.)?[A-Za-z0-9_]+$/; + +function categorize(node) { + const cf = node.callFrame || {}; + const fn = cf.functionName || ''; + const url = cf.url || ''; + + if (fn === '(root)' || fn === '(program)' || fn === '(idle)') return 'Idle'; + if (fn === '(garbage collector)' || fn === '(gc)' || /^gc\b/i.test(fn)) return 'GC'; + + if (!url) { + // Hermes/V8 builtins are URL-less; their function names look like + // `Object.assign`, `JSON.stringify`, `Array.prototype.map`. Bucket by + // the constructor so the cost of a whole intrinsic family aggregates. + const m = fn.match(BUILTIN_RE); + if (m) return `builtin:${m[1]}`; + return 'VM / native'; + } + + // Check SDK source before npm — if the SDK is profiled from a tarball install, + // the path will contain both `node_modules/stream-chat-react-native/...` AND + // match SDK_SOURCE_RE. We want it bucketed as SDK regardless of resolution. + if (SDK_SOURCE_RE.test(url)) return 'SDK source'; + + const pkg = url.match(NODE_MODULES_PKG_RE); + if (pkg) return `npm: ${pkg[1]}`; + + return 'App source'; +} + +// ---------- printers ------------------------------------------------------ + +function printSummary(profile, label = '') { + const totalDurationUs = profile.endTime - profile.startTime; + console.log(`\n${label ? '[' + label + '] ' : ''}Profile: ${profile.sourceFile || '(unknown)'}`); + console.log( + ` Duration: ${microsToMs(totalDurationUs)} ms Samples: ${profile.samples.length} ` + + `Sample rate: ${(profile.samples.length / (totalDurationUs / 1_000_000)).toFixed(0)}/s`, + ); + return totalDurationUs; +} + +function aggregateBy(nodesById, fn) { + // Aggregate self time by an arbitrary keyer. + const agg = new Map(); + for (const node of nodesById.values()) { + if (!node.selfTimeUs) continue; + const key = fn(node); + agg.set(key, (agg.get(key) || 0) + node.selfTimeUs); + } + return agg; +} + +function topN(agg, n) { + return Array.from(agg.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, n); +} + +function printTopBySelf(nodesById, totalDurationUs, limit = 25) { + const sorted = Array.from(nodesById.values()) + .filter((n) => n.selfTimeUs > 0) + .sort((a, b) => b.selfTimeUs - a.selfTimeUs) + .slice(0, limit); + + console.log('\n=== Top functions by SELF time ==='); + console.log(` ${pad('Self%', 6)} ${pad('Self ms', 8)} Function`); + for (const n of sorted) { + console.log( + ` ${pad(pct(n.selfTimeUs, totalDurationUs) + '%', 6)} ${pad(microsToMs(n.selfTimeUs) + 'ms', 8)} ${nodeLabel(n)}`, + ); + } +} + +function printTopByTotal(nodesById, totalDurationUs, limit = 25) { + const sorted = Array.from(nodesById.values()) + .filter((n) => n.totalTimeUs > 0) + .sort((a, b) => b.totalTimeUs - a.totalTimeUs) + .slice(0, limit); + + console.log('\n=== Top functions by TOTAL time (incl. callees) ==='); + console.log(` ${pad('Total%', 6)} ${pad('Total ms', 8)} Function`); + for (const n of sorted) { + console.log( + ` ${pad(pct(n.totalTimeUs, totalDurationUs) + '%', 6)} ${pad(microsToMs(n.totalTimeUs) + 'ms', 8)} ${nodeLabel(n)}`, + ); + } +} + +function printByCategory(nodesById, totalDurationUs) { + const byCat = aggregateBy(nodesById, categorize); + console.log('\n=== Time by category (self time, all frames) ==='); + console.log(` ${pad('Self%', 6)} ${pad('Self ms', 8)} Category`); + for (const [cat, us] of topN(byCat, 30)) { + console.log( + ` ${pad(pct(us, totalDurationUs) + '%', 6)} ${pad(microsToMs(us) + 'ms', 8)} ${cat}`, + ); + } +} + +function printByFile(nodesById, totalDurationUs, limit = 25) { + const byFile = aggregateBy(nodesById, shortFile); + console.log('\n=== Time by source file (self time) ==='); + console.log(` ${pad('Self%', 6)} ${pad('Self ms', 8)} File`); + for (const [file, us] of topN(byFile, limit)) { + console.log( + ` ${pad(pct(us, totalDurationUs) + '%', 6)} ${pad(microsToMs(us) + 'ms', 8)} ${file}`, + ); + } +} + +function findNodesByFunctionName(nodesById, fnName) { + return Array.from(nodesById.values()).filter( + (n) => (n.callFrame && n.callFrame.functionName) === fnName, + ); +} + +function collectDescendantsBySelf(nodesById, rootNodeId, agg = new Map(), seen = new Set()) { + if (seen.has(rootNodeId)) return agg; + seen.add(rootNodeId); + const node = nodesById.get(rootNodeId); + if (!node) return agg; + if (node.selfTimeUs) { + const key = nodeLabel(node); + agg.set(key, (agg.get(key) || 0) + node.selfTimeUs); + } + if (node.children) { + for (const childId of node.children) { + collectDescendantsBySelf(nodesById, childId, agg, seen); + } + } + return agg; +} + +function printInside(nodesById, fnName, totalDurationUs) { + const matches = findNodesByFunctionName(nodesById, fnName); + if (matches.length === 0) { + console.log(`\n=== Time inside ${fnName}: NOT FOUND in profile ===`); + return; + } + let totalInside = 0; + const aggregated = new Map(); + for (const m of matches) { + totalInside += m.totalTimeUs; + collectDescendantsBySelf(nodesById, m.id, aggregated); + } + console.log( + `\n=== Time inside ${fnName} (${matches.length} call frame${matches.length === 1 ? '' : 's'}, ${microsToMs(totalInside)}ms total = ${pct(totalInside, totalDurationUs)}% of profile) ===`, + ); + const sorted = Array.from(aggregated.entries()) + .filter(([_, us]) => us > 0) + .sort((a, b) => b[1] - a[1]) + .slice(0, 25); + console.log(` ${pad('Self%', 7)} ${pad('Self ms', 8)} Function`); + for (const [label, us] of sorted) { + console.log( + ` ${pad(pct(us, totalInside) + '%', 7)} ${pad(microsToMs(us) + 'ms', 8)} ${label}`, + ); + } +} + +// ---------- single-file mode -------------------------------------------- + +function detectUnsymbolicated(nodesById) { + // If nearly every URLed frame shares one bundle URL (e.g. `index.bundle?...`), + // the profile hasn't been mapped back to source — categorization will collapse + // into a single "App source" bucket. Warn so the user knows to desymbolicate. + const urlCounts = new Map(); + let urledFrames = 0; + for (const node of nodesById.values()) { + const url = node.callFrame && node.callFrame.url; + if (!url) continue; + urledFrames++; + urlCounts.set(url, (urlCounts.get(url) || 0) + 1); + } + if (urledFrames < 50) return false; // too small to judge + const top = [...urlCounts.values()].sort((a, b) => b - a)[0] || 0; + return top / urledFrames > 0.9 && urlCounts.size < 5; +} + +function analyzeSingle(filePath, options = {}) { + const profile = loadProfile(filePath); + profile.sourceFile = path.basename(filePath); + const totalDurationUs = printSummary(profile); + const nodesById = buildIndex(profile); + computeSelfTimes(profile, nodesById); + computeTotalTimes(nodesById); + + if (detectUnsymbolicated(nodesById)) { + console.log( + `\n⚠ This profile looks un-desymbolicated — nearly every frame shares a single bundle URL.`, + ); + console.log( + ` Per-package attribution won't work; everything will fall into a single "App source" bucket.`, + ); + console.log( + ` Desymbolicate first: node perf/desymbolicate-cpuprofile.js ${path.basename(filePath)} `, + ); + } + + printByCategory(nodesById, totalDurationUs); + printByFile(nodesById, totalDurationUs); + printTopBySelf(nodesById, totalDurationUs); + printTopByTotal(nodesById, totalDurationUs); + + // Optional drilldowns — pass `--inside Foo,Bar` to see what each named function + // spent its time on (self time of its descendants). No-op if a name isn't in + // the profile (minified, inlined, or never called). + for (const name of options.inside || []) { + printInside(nodesById, name, totalDurationUs); + } +} + +// ---------- diff mode ---------------------------------------------------- + +function buildAgg(filePath) { + const profile = loadProfile(filePath); + const nodesById = buildIndex(profile); + computeSelfTimes(profile, nodesById); + computeTotalTimes(nodesById); + + const totalDurationUs = profile.endTime - profile.startTime; + const byFn = aggregateBy(nodesById, nodeLabel); + const byCat = aggregateBy(nodesById, categorize); + const byFile = aggregateBy(nodesById, shortFile); + return { profile, totalDurationUs, byFn, byCat, byFile }; +} + +function printCategoryDiff(beforeAgg, afterAgg) { + const keys = new Set([...beforeAgg.byCat.keys(), ...afterAgg.byCat.keys()]); + const rows = []; + for (const k of keys) { + const b = beforeAgg.byCat.get(k) || 0; + const a = afterAgg.byCat.get(k) || 0; + rows.push({ + cat: k, + before: b, + after: a, + delta: a - b, + beforePct: pct(b, beforeAgg.totalDurationUs), + afterPct: pct(a, afterAgg.totalDurationUs), + }); + } + rows.sort((x, y) => Math.abs(y.delta) - Math.abs(x.delta)); + console.log('\n=== Category diff (self time) — sorted by abs delta ==='); + console.log( + ` ${pad('Before ms', 10)} ${pad('After ms', 10)} ${pad('Delta ms', 10)} ${pad('Before %', 9)} ${pad('After %', 9)} Category`, + ); + for (const r of rows) { + const arrow = r.delta < 0 ? '↓' : r.delta > 0 ? '↑' : '·'; + console.log( + ` ${pad(microsToMs(r.before), 10)} ${pad(microsToMs(r.after), 10)} ${pad((r.delta >= 0 ? '+' : '') + microsToMs(r.delta), 10)} ${pad(r.beforePct + '%', 9)} ${pad(r.afterPct + '%', 9)} ${arrow} ${r.cat}`, + ); + } +} + +function printFunctionDiff(beforeAgg, afterAgg, limit = 25) { + const keys = new Set([...beforeAgg.byFn.keys(), ...afterAgg.byFn.keys()]); + const rows = []; + for (const k of keys) { + const b = beforeAgg.byFn.get(k) || 0; + const a = afterAgg.byFn.get(k) || 0; + if (b === 0 && a === 0) continue; + rows.push({ fn: k, before: b, after: a, delta: a - b }); + } + rows.sort((x, y) => Math.abs(y.delta) - Math.abs(x.delta)); + console.log(`\n=== Top function deltas (self time, top ${limit} by |delta|) ===`); + console.log( + ` ${pad('Before ms', 10)} ${pad('After ms', 10)} ${pad('Delta ms', 10)} Function`, + ); + for (const r of rows.slice(0, limit)) { + const arrow = r.delta < 0 ? '↓' : r.delta > 0 ? '↑' : '·'; + console.log( + ` ${pad(microsToMs(r.before), 10)} ${pad(microsToMs(r.after), 10)} ${pad((r.delta >= 0 ? '+' : '') + microsToMs(r.delta), 10)} ${arrow} ${r.fn}`, + ); + } +} + +function printFunctionDiffFiltered(beforeAgg, afterAgg, pattern) { + let re; + try { + re = new RegExp(pattern, 'i'); + } catch (e) { + console.error(`Invalid --grep pattern: ${pattern} (${e.message})`); + return; + } + const keys = new Set([...beforeAgg.byFn.keys(), ...afterAgg.byFn.keys()]); + const rows = []; + for (const k of keys) { + if (!re.test(k)) continue; + const b = beforeAgg.byFn.get(k) || 0; + const a = afterAgg.byFn.get(k) || 0; + if (b === 0 && a === 0) continue; + rows.push({ fn: k, before: b, after: a, delta: a - b }); + } + rows.sort((x, y) => Math.abs(y.delta) - Math.abs(x.delta)); + console.log(`\n=== Function deltas matching /${pattern}/i (self time, all matches) ===`); + if (rows.length === 0) { + console.log(` (no matches)`); + return; + } + console.log( + ` ${pad('Before ms', 10)} ${pad('After ms', 10)} ${pad('Delta ms', 10)} Function`, + ); + for (const r of rows) { + const arrow = r.delta < 0 ? '↓' : r.delta > 0 ? '↑' : '·'; + console.log( + ` ${pad(microsToMs(r.before), 10)} ${pad(microsToMs(r.after), 10)} ${pad((r.delta >= 0 ? '+' : '') + microsToMs(r.delta), 10)} ${arrow} ${r.fn}`, + ); + } +} + +function analyzeDiff(beforePath, afterPath, options = {}) { + console.log( + `Diffing:\n before: ${path.basename(beforePath)}\n after: ${path.basename(afterPath)}`, + ); + const beforeAgg = buildAgg(beforePath); + const afterAgg = buildAgg(afterPath); + console.log( + `\nDurations — before: ${microsToMs(beforeAgg.totalDurationUs)}ms / after: ${microsToMs(afterAgg.totalDurationUs)}ms`, + ); + console.log( + `Samples — before: ${beforeAgg.profile.samples.length} / after: ${afterAgg.profile.samples.length}`, + ); + + // Sanity check: if sample rates diverge significantly, absolute ms deltas drift. + // Relative weights remain comparable but warn the reader. + const beforeRate = beforeAgg.profile.samples.length / (beforeAgg.totalDurationUs / 1_000_000); + const afterRate = afterAgg.profile.samples.length / (afterAgg.totalDurationUs / 1_000_000); + const rateSkew = Math.abs(afterRate - beforeRate) / Math.max(beforeRate, afterRate); + if (rateSkew > 0.1) { + console.log( + `\n⚠ Sample rates differ by ${(rateSkew * 100).toFixed(1)}% (${beforeRate.toFixed(0)}/s vs ${afterRate.toFixed(0)}/s).`, + ); + console.log(` Absolute ms deltas may be misleading; trust percentages over raw ms.`); + } else { + console.log( + `Note: durations should be similar for a fair comparison. Large differences mean the scenario timing varied; interpret with care.`, + ); + } + + printCategoryDiff(beforeAgg, afterAgg); + printFunctionDiff(beforeAgg, afterAgg); + if (options.grep) printFunctionDiffFiltered(beforeAgg, afterAgg, options.grep); +} + +// ---------- main --------------------------------------------------------- + +function parseArgs(argv) { + const out = { positional: [], inside: [], diff: false, grep: null }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--diff') out.diff = true; + else if (a === '--inside') out.inside = (argv[++i] || '').split(',').filter(Boolean); + else if (a === '--grep') out.grep = argv[++i] || null; + else if (a === '-h' || a === '--help') out.help = true; + else out.positional.push(a); + } + return out; +} + +function main() { + const opts = parseArgs(process.argv.slice(2)); + if (opts.help || opts.positional.length === 0) { + console.log(` +Usage: + node perf/analyze-cpuprofile.js [--inside Foo,Bar] + node perf/analyze-cpuprofile.js --diff [--grep ] + +Options: + --inside Comma-separated function names to drill into (single-file mode). + Shows where time inside each named function went. + --grep In diff mode, also print a filtered function diff matching this regex. +`); + process.exit(opts.help ? 0 : 1); + } + if (opts.diff) { + if (opts.positional.length !== 2) { + console.error('--diff requires exactly two .cpuprofile paths'); + process.exit(1); + } + analyzeDiff(opts.positional[0], opts.positional[1], { grep: opts.grep }); + } else { + analyzeSingle(opts.positional[0], { inside: opts.inside }); + } +} + +main(); diff --git a/perf/analyze-react-profile.js b/perf/analyze-react-profile.js new file mode 100644 index 0000000000..268f18c179 --- /dev/null +++ b/perf/analyze-react-profile.js @@ -0,0 +1,370 @@ +#!/usr/bin/env node + +/** + * Analyze a React DevTools Profiler export (the JSON you get from "Save + * profile" in the Profiler tab — NOT a Hermes .cpuprofile; that's a + * different format handled by analyze-cpuprofile.js). + * + * Usage: + * node perf/analyze-react-profile.js path/to/profile.json + * + * Output: top components by total self time, by total actual time, by avg + * self time per render; slowest single commits; what triggered each commit; + * focused breakdown for Message-row components. All numbers in ms. + * + * Implementation notes: + * - `snapshots` only contains the fiber tree at the START of the profile. + * Fibers that mount DURING the profile (the common case for scroll work) + * have their displayName encoded into the per-commit `operations` stream + * as length-prefixed UTF-8 entries. We decode that stream so message + * rows etc. get real names, not ``. + * - Operation codes (React DevTools constants): + * 1 = TREE_OPERATION_ADD + * 2 = TREE_OPERATION_REMOVE + * 3 = TREE_OPERATION_REORDER_CHILDREN + * 4 = TREE_OPERATION_UPDATE_TREE_BASE_DURATION + * 5 = TREE_OPERATION_REMOVE_ROOT + * 6 = TREE_OPERATION_SET_SUBTREE_MODE + */ + +const fs = require('fs'); +const path = require('path'); + +const profilePath = process.argv[2]; +if (!profilePath) { + console.error('usage: node perf/analyze-react-profile.js '); + process.exit(1); +} + +const data = JSON.parse(fs.readFileSync(profilePath, 'utf8')); +const root = data.dataForRoots[0]; + +// ---- fiberId -> displayName from snapshots (initial tree) --------------- +const fiberInfo = new Map(); +for (const [id, info] of root.snapshots) { + fiberInfo.set(id, info); +} + +// ---- Decode operations to learn names of fibers that mount during profile + +// op codes +const OP_ADD = 1; +const OP_REMOVE = 2; +const OP_REORDER = 3; +const OP_UPDATE_BASE = 4; +const OP_REMOVE_ROOT = 5; +const OP_SET_SUBTREE_MODE = 6; + +function decodeOperations(ops) { + // ops[0] = rendererID, ops[1] = rootID, ops[2] = stringTableLen + const stringTableLen = ops[2]; + let i = 3; + // Build a 1-indexed string table for this commit + const strings = [null]; // index 0 means "null/no string" + const tableEnd = i + stringTableLen; + while (i < tableEnd) { + const len = ops[i++]; + // bytes follow as UTF-8 code points + const bytes = ops.slice(i, i + len); + i += len; + strings.push(Buffer.from(bytes).toString('utf8')); + } + // Now consume operation entries until end of array + while (i < ops.length) { + const code = ops[i++]; + switch (code) { + case OP_ADD: { + const id = ops[i++]; + const type = ops[i++]; + if (type === 11) { + // Root: parentID(0), isStrictMode, supportsProfiling, supportsStrictMode, hasOwnerMetadata + i += 5; + if (!fiberInfo.has(id)) fiberInfo.set(id, { id, type, displayName: 'Root' }); + } else { + const parentID = ops[i++]; + const ownerID = ops[i++]; + const displayNameStringID = ops[i++]; + const keyStringID = ops[i++]; + const displayName = strings[displayNameStringID] || null; + const key = strings[keyStringID] || null; + if (!fiberInfo.has(id)) { + fiberInfo.set(id, { id, type, parentID, ownerID, displayName, key }); + } else { + // Refresh in case snapshots had it with no displayName + const existing = fiberInfo.get(id); + if (!existing.displayName && displayName) existing.displayName = displayName; + } + } + break; + } + case OP_REMOVE: { + const n = ops[i++]; + i += n; + break; + } + case OP_REORDER: { + i++; // id + const n = ops[i++]; + i += n; + break; + } + case OP_UPDATE_BASE: { + i += 2; + break; + } + case OP_REMOVE_ROOT: { + // no args + break; + } + case OP_SET_SUBTREE_MODE: { + i += 2; + break; + } + default: { + // unknown — bail out of this commit to avoid corrupting future reads + return; + } + } + } +} + +for (const ops of root.operations) { + try { + decodeOperations(ops); + } catch (e) { + // tolerate one malformed commit; keep going + } +} + +const nameOf = (id) => { + const info = fiberInfo.get(id); + if (!info) return ``; + return info.displayName || ``; +}; + +// ---- Per-name aggregation ------------------------------------------------ +const perName = new Map(); +function bump(name, fiberId, selfMs, actualMs) { + let e = perName.get(name); + if (!e) { + e = { selfMs: 0, actualMs: 0, renderCount: 0, fiberIds: new Set(), maxSelf: 0, maxActual: 0 }; + perName.set(name, e); + } + e.selfMs += selfMs; + e.actualMs += actualMs; + e.renderCount += 1; + e.fiberIds.add(fiberId); + if (selfMs > e.maxSelf) e.maxSelf = selfMs; + if (actualMs > e.maxActual) e.maxActual = actualMs; +} + +const commits = []; +let totalCommitDuration = 0; +let earliestTs = Infinity; +let latestTs = -Infinity; + +for (const commit of root.commitData) { + const selfMap = new Map(commit.fiberSelfDurations || []); + const actualMap = new Map(commit.fiberActualDurations || []); + const ids = new Set([...selfMap.keys(), ...actualMap.keys()]); + let commitSelf = 0; + const commitContributors = []; + for (const id of ids) { + const name = nameOf(id); + const selfMs = selfMap.get(id) ?? 0; + const actualMs = actualMap.get(id) ?? 0; + bump(name, id, selfMs, actualMs); + commitSelf += selfMs; + commitContributors.push({ name, id, selfMs, actualMs }); + } + commits.push({ + duration: commit.duration, + effectDuration: commit.effectDuration, + passiveEffectDuration: commit.passiveEffectDuration, + priorityLevel: commit.priorityLevel, + timestamp: commit.timestamp, + commitSelf, + contributors: commitContributors, + updaters: commit.updaters || [], + }); + totalCommitDuration += commit.duration || 0; + if (commit.timestamp < earliestTs) earliestTs = commit.timestamp; + if (commit.timestamp > latestTs) latestTs = commit.timestamp; +} + +// ---- Output -------------------------------------------------------------- +const pad = (s, n) => String(s).padEnd(n); +const padR = (s, n) => String(s).padStart(n); +const fmt = (ms) => `${ms.toFixed(2)}ms`; + +console.log(`\n=== Profile summary ===`); +console.log(`File: ${path.resolve(profilePath)}`); +console.log(`Root: ${root.displayName} (rootID=${root.rootID})`); +console.log(`Commits: ${commits.length}`); +console.log(`Wall-time span: ${(latestTs - earliestTs).toFixed(0)} ms`); +console.log(`Sum of commit durations: ${totalCommitDuration.toFixed(1)} ms`); +console.log(`Avg commit duration: ${(totalCommitDuration / commits.length).toFixed(2)} ms`); +console.log( + `Resolved fiber names: ${[...fiberInfo.values()].filter((f) => f.displayName).length} / ${fiberInfo.size}`, +); + +const allEntries = [...perName.entries()].map(([name, e]) => ({ name, ...e })); + +console.log(`\n=== Top 40 components by total SELF time ===`); +console.log( + `${pad('Component', 50)} ${padR('SelfMs', 10)} ${padR('Renders', 8)} ${padR('Avg', 8)} ${padR('Max', 8)}`, +); +allEntries + .sort((a, b) => b.selfMs - a.selfMs) + .slice(0, 40) + .forEach((e) => { + console.log( + `${pad(e.name.slice(0, 50), 50)} ${padR(e.selfMs.toFixed(2), 10)} ${padR(e.renderCount, 8)} ${padR((e.selfMs / e.renderCount).toFixed(3), 8)} ${padR(e.maxSelf.toFixed(2), 8)}`, + ); + }); + +console.log(`\n=== Top 25 components by total ACTUAL time (incl. children) ===`); +console.log( + `${pad('Component', 50)} ${padR('ActualMs', 10)} ${padR('Renders', 8)} ${padR('Avg', 8)} ${padR('Max', 8)}`, +); +allEntries + .sort((a, b) => b.actualMs - a.actualMs) + .slice(0, 25) + .forEach((e) => { + console.log( + `${pad(e.name.slice(0, 50), 50)} ${padR(e.actualMs.toFixed(2), 10)} ${padR(e.renderCount, 8)} ${padR((e.actualMs / e.renderCount).toFixed(3), 8)} ${padR(e.maxActual.toFixed(2), 8)}`, + ); + }); + +console.log(`\n=== Top 25 by AVERAGE self time per render (min 3 renders) ===`); +console.log(`${pad('Component', 50)} ${padR('Avg', 10)} ${padR('Max', 10)} ${padR('Renders', 8)}`); +allEntries + .filter((e) => e.renderCount >= 3) + .sort((a, b) => b.selfMs / b.renderCount - a.selfMs / a.renderCount) + .slice(0, 25) + .forEach((e) => { + console.log( + `${pad(e.name.slice(0, 50), 50)} ${padR((e.selfMs / e.renderCount).toFixed(3), 10)} ${padR(e.maxSelf.toFixed(2), 10)} ${padR(e.renderCount, 8)}`, + ); + }); + +console.log(`\n=== Top 25 single-render hits across all commits ===`); +console.log(`${pad('Component', 50)} ${padR('SelfMs', 10)} ${padR('ActualMs', 10)} (commit ts)`); +const allHits = []; +for (const c of commits) { + for (const x of c.contributors) { + allHits.push({ ...x, ts: c.timestamp }); + } +} +allHits + .sort((a, b) => b.selfMs - a.selfMs) + .slice(0, 25) + .forEach((h) => { + console.log( + `${pad(h.name.slice(0, 50), 50)} ${padR(h.selfMs.toFixed(2), 10)} ${padR(h.actualMs.toFixed(2), 10)} (t=${h.ts.toFixed(0)})`, + ); + }); + +console.log(`\n=== Top 12 SLOWEST commits ===`); +commits + .slice() + .sort((a, b) => (b.duration || 0) - (a.duration || 0)) + .slice(0, 12) + .forEach((c, i) => { + const top = c.contributors + .slice() + .sort((a, b) => b.selfMs - a.selfMs) + .slice(0, 6) + .map((x) => `${x.name}@${x.selfMs.toFixed(1)}`) + .join(', '); + const updaters = (c.updaters || []) + .map((u) => u.displayName || `<${u.id}>`) + .slice(0, 4) + .join(', '); + console.log( + `${i + 1}. duration=${fmt(c.duration || 0)} (self=${fmt(c.commitSelf)}, ${c.contributors.length} fibers, prio=${c.priorityLevel}, t=${c.timestamp.toFixed(0)})`, + ); + console.log(` updaters: ${updaters || '(none recorded)'}`); + console.log(` top self: ${top}`); + }); + +console.log(`\n=== Top 20 UPDATERS by # of commits triggered ===`); +const updaterCount = new Map(); +for (const c of commits) { + for (const u of c.updaters || []) { + const k = u.displayName || ``; + updaterCount.set(k, (updaterCount.get(k) || 0) + 1); + } +} +[...updaterCount.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 20) + .forEach(([name, n]) => { + console.log(`${pad(name.slice(0, 60), 60)} ${padR(n, 6)}`); + }); + +// ---- Focused: Message subtree ------------------------------------------- +const messageNamesRe = + /^(Message|MessageWithContext|MemoizedMessage|MessageItemView|MessageItemViewWithContext|MemoizedMessageItemView|MessageContent|MessageContentWithContext|MemoizedMessageContent|MessageFooter|MessageTextContainer|MessageBubble|SwipableMessageWrapper|MessageWrapper|MessageAvatar|MessageAuthor|MessageStatus|MessageTimestamp|MessageReplies|MessageHeader|MessageList|MessageListWithContext|MessageFlashList|MessageFlashListWithContext|MessageSimple|MessageInput|MessageInputWithContext|TypingIndicator|ReactionList)$/; + +console.log(`\n=== Message-subtree focus (all matching components, by self time) ===`); +console.log( + `${pad('Component', 50)} ${padR('SelfMs', 10)} ${padR('ActualMs', 10)} ${padR('Renders', 8)} ${padR('Avg', 8)} ${padR('Max', 8)}`, +); +allEntries + .filter((e) => messageNamesRe.test(e.name)) + .sort((a, b) => b.selfMs - a.selfMs) + .forEach((e) => { + console.log( + `${pad(e.name.slice(0, 50), 50)} ${padR(e.selfMs.toFixed(2), 10)} ${padR(e.actualMs.toFixed(2), 10)} ${padR(e.renderCount, 8)} ${padR((e.selfMs / e.renderCount).toFixed(3), 8)} ${padR(e.maxSelf.toFixed(2), 8)}`, + ); + }); + +// ---- Heuristic buckets -------------------------------------------------- +const heuristics = [ + { label: 'Markdown', re: /Markdown|markdown|MDX|renderText|MDRender/i }, + { label: 'Reanimated', re: /Animated|Reanimated|SharedValue/i }, + { label: 'Gesture/RNGH', re: /Gesture|GestureDetector|GestureHandler/i }, + { label: 'FlatList/FlashList', re: /FlatList|FlashList|VirtualizedList|CellRenderer/i }, + { label: 'Image', re: /Image|FastImage|Gallery/i }, + { label: 'Pressable/Touchable', re: /^Pressable$|TouchableOpacity|TouchableHighlight/ }, + { label: 'Context.Provider', re: /Provider$/ }, + { label: 'Memo/ForwardRef wrappers', re: /^Memo|^ForwardRef/ }, + { label: 'Stream Channel*/Chat*/Thread*', re: /^(Channel|Chat|Thread)/ }, + { label: 'Stream Message*', re: /^Message/ }, + { label: 'Reactions', re: /Reaction/ }, + { label: 'Attachments', re: /Attachment|Audio|Video|File|Card/i }, + { label: 'Poll', re: /Poll/ }, + { label: 'Avatar / Author / Status', re: /Avatar|Author|Status/ }, +]; +console.log(`\n=== Heuristic buckets — total self time across bucket ===`); +console.log(`${pad('Bucket', 42)} ${padR('SelfMs', 8)} ${padR('Renders', 8)} top members`); +for (const h of heuristics) { + const hits = allEntries.filter((e) => h.re.test(e.name)); + const sumSelf = hits.reduce((s, x) => s + x.selfMs, 0); + const sumRenders = hits.reduce((s, x) => s + x.renderCount, 0); + const topNames = hits + .slice() + .sort((a, b) => b.selfMs - a.selfMs) + .slice(0, 6) + .map((x) => `${x.name}(${x.selfMs.toFixed(0)}ms,${x.renderCount}r)`) + .join(', '); + console.log( + `${pad(h.label, 42)} ${padR(sumSelf.toFixed(1), 8)} ${padR(sumRenders, 8)} ${topNames}`, + ); +} + +console.log(`\n=== Components rendered with the most distinct fiber instances ===`); +console.log( + `${pad('Component', 50)} ${padR('Fibers', 8)} ${padR('Renders', 8)} ${padR('SelfMs', 10)}`, +); +allEntries + .sort((a, b) => b.fiberIds.size - a.fiberIds.size) + .slice(0, 25) + .forEach((e) => { + console.log( + `${pad(e.name.slice(0, 50), 50)} ${padR(e.fiberIds.size, 8)} ${padR(e.renderCount, 8)} ${padR(e.selfMs.toFixed(2), 10)}`, + ); + }); + +console.log(`\n=== Done ===\n`); diff --git a/perf/capture-hermes-profile.js b/perf/capture-hermes-profile.js new file mode 100644 index 0000000000..1273d20946 --- /dev/null +++ b/perf/capture-hermes-profile.js @@ -0,0 +1,271 @@ +#!/usr/bin/env node + +/** + * Capture a Hermes CPU profile from a running React Native app via Metro's + * inspector WebSocket. Works on iOS and Android with no app code changes + * and no react-native-fs / native modules. + * + * Prereqs: + * 1. Metro running (e.g. `yarn workspace sampleapp start`). + * 2. SampleApp open on a device or simulator (Hermes is on by default in + * RN 0.70+). + * + * Usage: + * node perf/capture-hermes-profile.js [output-path] + * + * Flow: + * 1. Asks Metro for its list of debug targets at http://localhost:8081/json/list + * 2. Connects to the Hermes target's webSocketDebuggerUrl + * 3. Sends Profiler.enable + Profiler.start (Chrome DevTools Protocol) + * 4. Waits for you to press Enter + * 5. Sends Profiler.stop, receives the profile JSON + * 6. Writes to disk as a `.cpuprofile` + * + * Then: `node perf/analyze-cpuprofile.js ` + */ + +const fs = require('fs'); +const http = require('http'); +const path = require('path'); +const readline = require('readline'); + +const OUT = + process.argv[2] || + path.join( + __dirname, + 'profiles', + `hermes-${new Date().toISOString().replace(/[:.]/g, '-')}.cpuprofile`, + ); + +const METRO_HOST = process.env.METRO_HOST || 'localhost'; +const METRO_PORT = process.env.METRO_PORT || '8081'; + +function httpGetJson(url) { + return new Promise((resolve, reject) => { + http + .get(url, (res) => { + let body = ''; + res.on('data', (d) => (body += d)); + res.on('end', () => { + try { + resolve(JSON.parse(body)); + } catch (e) { + reject(e); + } + }); + }) + .on('error', reject); + }); +} + +function prompt(msg) { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + return new Promise((resolve) => + rl.question(msg, (ans) => { + rl.close(); + resolve(ans); + }), + ); +} + +function pickTarget(targets) { + // Prefer something that mentions Hermes / RNRuntime; fall back to first with a JS context. + const score = (t) => { + const s = `${t.title || ''} ${t.deviceName || ''} ${t.type || ''}`.toLowerCase(); + let n = 0; + if (s.includes('hermes')) n += 5; + if (s.includes('rnruntime') || s.includes('react native')) n += 3; + if (t.webSocketDebuggerUrl) n += 1; + return n; + }; + return targets.filter((t) => t.webSocketDebuggerUrl).sort((a, b) => score(b) - score(a))[0]; +} + +async function main() { + console.log(`Looking for Metro at http://${METRO_HOST}:${METRO_PORT} ...`); + let targets; + try { + targets = await httpGetJson(`http://${METRO_HOST}:${METRO_PORT}/json/list`); + } catch (e) { + console.error(`Could not reach Metro at ${METRO_HOST}:${METRO_PORT}.`); + console.error('Make sure `yarn workspace sampleapp start` is running and the app is open.'); + process.exit(1); + } + + if (!Array.isArray(targets) || targets.length === 0) { + console.error('Metro reported no debug targets. Is the app open on a device/simulator?'); + process.exit(1); + } + + const target = pickTarget(targets); + if (!target) { + console.error('No debug target had a webSocketDebuggerUrl. Targets:'); + console.error(JSON.stringify(targets, null, 2)); + process.exit(1); + } + + console.log(`Found target: ${target.title || '(no title)'} — ${target.deviceName || ''}`); + console.log(`Connecting: ${target.webSocketDebuggerUrl}`); + + // Node 22+ has global WebSocket; older Node would need `ws`. + if (typeof WebSocket === 'undefined') { + console.error( + 'Global WebSocket is not available — you are on Node < 22. Either upgrade Node or `yarn add -D ws` and let me know.', + ); + process.exit(1); + } + const ws = new WebSocket(target.webSocketDebuggerUrl); + + let msgId = 0; + const pending = new Map(); + const send = (method, params = {}) => + new Promise((resolve, reject) => { + const id = ++msgId; + pending.set(id, { resolve, reject }); + ws.send(JSON.stringify({ id, method, params })); + }); + + ws.addEventListener('message', (evt) => { + let msg; + try { + msg = JSON.parse(evt.data); + } catch { + return; + } + if (msg.id && pending.has(msg.id)) { + const p = pending.get(msg.id); + pending.delete(msg.id); + if (msg.error) p.reject(new Error(msg.error.message)); + else p.resolve(msg.result); + } + }); + + await new Promise((resolve, reject) => { + ws.addEventListener('open', () => resolve()); + ws.addEventListener('error', (e) => reject(new Error(`WS error: ${e.message || e}`))); + }); + + // RN 0.81 Bridgeless / Fusebox dropped the CDP `Profiler` domain. The + // working alternatives are (a) the Tracing domain, (b) calling + // HermesInternal.enableSamplingProfiler directly via Runtime.evaluate. + // We try (a) first — easier to parse — then fall back to (b). + + const tryProfilerCdp = async () => { + try { + await send('Profiler.enable'); + await send('Profiler.start'); + return 'profiler'; + } catch { + return null; + } + }; + + const tryTracing = async () => { + try { + // Chrome trace category that emits CPU samples + await send('Tracing.start', { + categories: + '-*,disabled-by-default-v8.cpu_profiler.hires,disabled-by-default-v8.cpu_profiler', + options: 'sampling-frequency=10000', + transferMode: 'ReturnAsStream', + }); + return 'tracing'; + } catch { + return null; + } + }; + + const tryHermesInternal = async () => { + const res = await send('Runtime.evaluate', { + expression: + 'typeof HermesInternal === "object" && typeof HermesInternal.enableSamplingProfiler === "function"', + returnByValue: true, + }); + if (res?.result?.value !== true) return null; + await send('Runtime.evaluate', { + expression: 'HermesInternal.enableSamplingProfiler(true)', + returnByValue: true, + }); + return 'hermes'; + }; + + console.log('Connected. Probing profiler support ...'); + let mode = await tryProfilerCdp(); + if (!mode) mode = await tryTracing(); + if (!mode) mode = await tryHermesInternal(); + if (!mode) { + console.error('None of Profiler / Tracing / HermesInternal worked on this target. Aborting.'); + process.exit(1); + } + console.log(`Using ${mode} mode.`); + + console.log('\n=== PROFILING. Do your scenario now (open channel, scroll, etc). ==='); + await prompt('Press Enter to STOP and save the profile: '); + + console.log('Stopping profile ...'); + let profileJson; + + if (mode === 'profiler') { + const result = await send('Profiler.stop'); + profileJson = result?.profile; + } else if (mode === 'tracing') { + const collected = []; + const onMessage = (evt) => { + const msg = JSON.parse(evt.data); + if (msg.method === 'Tracing.dataCollected') { + collected.push(...(msg.params?.value || [])); + } + }; + ws.addEventListener('message', onMessage); + await send('Tracing.end'); + // Wait briefly for Tracing.tracingComplete + dataCollected events + await new Promise((r) => setTimeout(r, 1500)); + profileJson = collected; + } else if (mode === 'hermes') { + // For HermesInternal mode we need an absolute path the app can write to. + // iOS Simulator inherits the host's /tmp — works directly. For an iOS + // device or Android device this path won't work; user will need to grab + // the file off-device. + const devicePath = `/tmp/hermes-${Date.now()}.cpuprofile`; + await send('Runtime.evaluate', { + expression: `HermesInternal.dumpSampledTraceToFile(${JSON.stringify(devicePath)})`, + returnByValue: true, + }); + await send('Runtime.evaluate', { + expression: 'HermesInternal.enableSamplingProfiler(false)', + returnByValue: true, + }); + console.log(`\nProfile written by the app to: ${devicePath}`); + console.log('If you are on iOS Simulator, that path is accessible directly on your Mac.'); + console.log( + 'If you are on an iOS device, you will need to pull it via Xcode Devices → app sandbox.', + ); + ws.close(); + process.exit(0); + } + + if (!profileJson) { + console.error('No profile returned. Mode:', mode); + process.exit(1); + } + + fs.mkdirSync(path.dirname(OUT), { recursive: true }); + fs.writeFileSync(OUT, JSON.stringify(profileJson)); + const stats = fs.statSync(OUT); + console.log(`\nSaved ${(stats.size / 1024).toFixed(1)} KB profile to:`); + console.log(` ${OUT}`); + + ws.close(); + + // Chain into the analyzer so the user doesn't have to copy-paste a path. + const { spawnSync } = require('child_process'); + const analyzer = path.join(__dirname, 'analyze-cpuprofile.js'); + console.log(`\nRunning analyzer:\n node ${analyzer} ${OUT}\n`); + const r = spawnSync(process.execPath, [analyzer, OUT], { stdio: 'inherit' }); + process.exit(r.status ?? 0); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/perf/capture-server.js b/perf/capture-server.js new file mode 100644 index 0000000000..d267c908c4 --- /dev/null +++ b/perf/capture-server.js @@ -0,0 +1,270 @@ +#!/usr/bin/env node +/** + * Long-form Hermes profiling capture. Connects to Metro's inspector, streams + * Tracing.dataCollected events during the entire capture (the listener is + * installed BEFORE Tracing.start, which is what makes this work — the old + * capture-hermes-profile.js installs the listener after Tracing.end and so + * only sees the post-end flush, limiting captures to ~2-3s). + * + * Usage: + * node perf/capture-server.js [label] + * PERF_MAP=path/to/bundle.map node perf/capture-server.js [label] + */ + +const { spawnSync } = require('child_process'); +const fs = require('fs'); +const http = require('http'); +const os = require('os'); +const path = require('path'); +const readline = require('readline'); + +const METRO_HOST = process.env.METRO_HOST || 'localhost'; +const METRO_PORT = process.env.METRO_PORT || '8081'; +const PROFILES_DIR = path.join(__dirname, 'profiles'); +const MAP_PATH = process.env.PERF_MAP ? path.resolve(process.env.PERF_MAP) : null; +const LABEL = (process.argv[2] || 'capture').replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 80); + +fs.mkdirSync(PROFILES_DIR, { recursive: true }); + +if (typeof WebSocket === 'undefined') { + console.error('Global WebSocket missing — run on Node 22+.'); + process.exit(1); +} + +function httpGetJson(url) { + return new Promise((resolve, reject) => { + http + .get(url, (res) => { + let body = ''; + res.on('data', (d) => (body += d)); + res.on('end', () => { + try { + resolve(JSON.parse(body)); + } catch (e) { + reject(e); + } + }); + }) + .on('error', reject); + }); +} + +function downloadToFile(url, outPath) { + return new Promise((resolve, reject) => { + const file = fs.createWriteStream(outPath); + http + .get(url, (res) => { + if (res.statusCode !== 200) { + file.destroy(); + return reject(new Error(`HTTP ${res.statusCode} for ${url}`)); + } + res.pipe(file); + file.on('finish', () => file.close(() => resolve(outPath))); + file.on('error', reject); + }) + .on('error', reject); + }); +} + +// Find the first Metro bundle URL in a Chrome trace event array (the shape +// capture-server.js writes — Tracing.dataCollected payload). We look at the +// ProfileChunk events' embedded cpuProfile nodes. +function findBundleUrlInTrace(events) { + for (const ev of events || []) { + const nodes = ev?.args?.data?.cpuProfile?.nodes; + if (!nodes) continue; + for (const n of nodes) { + const url = n?.callFrame?.url; + if (typeof url === 'string' && /\.bundle\b/.test(url)) return url; + } + } + return null; +} + +// Source map sits at the same path as the bundle, but with .bundle → .map. +// Hermes / Metro inspector emits a malformed URL like +// http://host/index.bundle//&platform=android&dev=true&... +// where the query string is glued on with `//&` instead of `?`. We can't use +// the WHATWG URL parser (it would treat `//&...` as path, not query). Parse +// regex-style, strip the leading `/*[?&]` separators, and rebuild as +// /index.map?. Also force the host — the captured URL may be +// 10.0.2.2 (Android emulator) or a LAN IP unreachable from this script. +function deriveMapUrl(bundleUrl) { + const m = bundleUrl.match(/^https?:\/\/[^/]+\/(.+?)\.bundle\/*[?&]?(.*)$/); + if (!m) return null; + const basePath = m[1]; + const query = m[2]; + return `http://${METRO_HOST}:${METRO_PORT}/${basePath}.map${query ? '?' + query : ''}`; +} + +function pickTarget(targets) { + const score = (t) => { + const s = `${t.title || ''} ${t.deviceName || ''} ${t.type || ''}`.toLowerCase(); + let n = 0; + if (s.includes('hermes')) n += 5; + if (s.includes('rnruntime') || s.includes('react native')) n += 3; + if (t.webSocketDebuggerUrl) n += 1; + return n; + }; + return targets.filter((t) => t.webSocketDebuggerUrl).sort((a, b) => score(b) - score(a))[0]; +} + +function prompt(msg) { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + return new Promise((resolve) => + rl.question(msg, (ans) => { + rl.close(); + resolve(ans); + }), + ); +} + +async function main() { + console.log(`Connecting to Metro at ${METRO_HOST}:${METRO_PORT} ...`); + let targets; + try { + targets = await httpGetJson(`http://${METRO_HOST}:${METRO_PORT}/json/list`); + } catch (e) { + console.error(`✗ Could not reach Metro. Is \`yarn workspace sampleapp start\` running?`); + process.exit(1); + } + if (!Array.isArray(targets) || targets.length === 0) { + console.error('✗ Metro reported no debug targets — is the app open?'); + process.exit(1); + } + const target = pickTarget(targets); + if (!target) { + console.error('✗ No Hermes target with a webSocketDebuggerUrl.'); + process.exit(1); + } + console.log( + ` ↳ ${target.title || '(no title)'} ${target.deviceName ? `(${target.deviceName})` : ''}`, + ); + + const ws = new WebSocket(target.webSocketDebuggerUrl); + let msgId = 0; + const pending = new Map(); + const tracingEvents = []; + + const send = (method, params = {}) => + new Promise((resolve, reject) => { + const id = ++msgId; + pending.set(id, { resolve, reject }); + ws.send(JSON.stringify({ id, method, params })); + }); + + // KEY: this listener has to be installed BEFORE Tracing.start, so it catches + // dataCollected events streaming during the whole capture, not just the + // post-end flush. That's what makes long captures work. + ws.addEventListener('message', (evt) => { + let msg; + try { + msg = JSON.parse(evt.data); + } catch { + return; + } + if (msg.id && pending.has(msg.id)) { + const p = pending.get(msg.id); + pending.delete(msg.id); + if (msg.error) p.reject(new Error(msg.error.message)); + else p.resolve(msg.result); + return; + } + if (msg.method === 'Tracing.dataCollected') { + const arr = msg.params?.value || []; + for (const ev of arr) tracingEvents.push(ev); + } + }); + + await new Promise((resolve, reject) => { + ws.addEventListener('open', () => resolve()); + ws.addEventListener('error', (e) => reject(new Error(`WS error: ${e.message || e}`))); + }); + + // sampling-frequency in microseconds between samples → 10000us = 100Hz. + // Lower frequency (higher value) = lighter device overhead, longer coverage. + await send('Tracing.start', { + categories: '-*,disabled-by-default-v8.cpu_profiler.hires,disabled-by-default-v8.cpu_profiler', + options: 'sampling-frequency=10000', + transferMode: 'ReportEvents', + }); + + console.log(`\nTracing started. Run your scenario on device.`); + await prompt(`Press Enter to STOP and save: `); + + console.log('Stopping ...'); + await send('Tracing.end'); + // Wait briefly for any tail dataCollected events Hermes still has buffered. + await new Promise((r) => setTimeout(r, 1500)); + try { + ws.close(); + } catch { + /* ignore */ + } + + if (tracingEvents.length === 0) { + console.error('✗ No samples collected — was the app idle the whole time?'); + process.exit(1); + } + + const ts = new Date().toISOString().replace(/[:.]/g, '-'); + const outPath = path.join(PROFILES_DIR, `${LABEL}-${ts}.cpuprofile`); + fs.writeFileSync(outPath, JSON.stringify(tracingEvents)); + const sizeKb = (fs.statSync(outPath).size / 1024).toFixed(1); + console.log(`Saved ${sizeKb} KB → ${path.relative(process.cwd(), outPath)}\n`); + + // Resolve the source map. Priority: + // 1) PERF_MAP= — explicit override (release builds, offline cases). + // 2) Auto-fetch from Metro using the bundle URL embedded in the profile. + // 3) Skip entirely if PERF_SKIP_DESYM=1. + // Auto-fetched maps go to /tmp and get cleaned up after desym runs. + let mapPath = MAP_PATH && fs.existsSync(MAP_PATH) ? MAP_PATH : null; + let autoFetchedMap = null; + if (!mapPath && !process.env.PERF_SKIP_DESYM) { + const bundleUrl = findBundleUrlInTrace(tracingEvents); + const mapUrl = bundleUrl ? deriveMapUrl(bundleUrl) : null; + if (!mapUrl) { + console.log('No .bundle URL in profile — skipping auto-desymbolication.'); + } else { + const tmpMap = path.join(os.tmpdir(), `metro-${Date.now()}.map.json`); + console.log(`Fetching source map:\n ${mapUrl}`); + try { + await downloadToFile(mapUrl, tmpMap); + mapPath = tmpMap; + autoFetchedMap = tmpMap; + } catch (e) { + console.error(`Could not fetch source map (${e.message}) — analyzing raw profile.`); + } + } + } + + let analyzeTarget = outPath; + if (mapPath && fs.existsSync(mapPath)) { + console.log(`Desymbolicating with ${mapPath} ...`); + const desym = spawnSync( + process.execPath, + [path.join(__dirname, 'desymbolicate-cpuprofile.js'), outPath, mapPath], + { stdio: 'inherit' }, + ); + if (desym.status === 0) { + const candidate = outPath.replace(/\.cpuprofile$/, '') + '.desymbolicated.cpuprofile'; + if (fs.existsSync(candidate)) analyzeTarget = candidate; + } else { + console.error('Desymbolication failed — analyzing raw profile.'); + } + if (autoFetchedMap && fs.existsSync(autoFetchedMap)) fs.unlinkSync(autoFetchedMap); + } + + console.log(`\nAnalyzing ${path.basename(analyzeTarget)} ...\n`); + const analyzerArgs = [path.join(__dirname, 'analyze-cpuprofile.js'), analyzeTarget]; + if (process.env.PERF_INSIDE) { + analyzerArgs.push('--inside', process.env.PERF_INSIDE); + } + const r = spawnSync(process.execPath, analyzerArgs, { stdio: 'inherit' }); + process.exit(r.status ?? 0); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/perf/desymbolicate-cpuprofile.js b/perf/desymbolicate-cpuprofile.js new file mode 100644 index 0000000000..0b9562a39c --- /dev/null +++ b/perf/desymbolicate-cpuprofile.js @@ -0,0 +1,187 @@ +#!/usr/bin/env node +/** + * Rewrite a Hermes/V8 .cpuprofile so its frames point at original source + * locations instead of minified bundle offsets. Uses a single Metro source map. + * + * Usage: + * node perf/desymbolicate-cpuprofile.js [-o ] + * + * Input profile may be either: + * - V8 .cpuprofile JSON ({ nodes, samples, timeDeltas, ... }) + * - Chrome trace event array (RN 0.81 Bridgeless / Fusebox Tracing format) + * + * Output is always V8 .cpuprofile JSON, which analyze-cpuprofile.js consumes. + * + * Per frame we look up (line + 1, column) in the source map and rewrite: + * callFrame.url -> original source path (e.g. package/src/foo.tsx) + * callFrame.lineNumber -> original line - 1 (cpuprofile is 0-indexed) + * callFrame.columnNumber -> original column + * callFrame.functionName -> source map `name` if present, else keep existing + * + * Pseudo-frames ((root), (idle), (gc), GC, builtins with no URL) are passed + * through untouched. + */ + +const fs = require('fs'); +const path = require('path'); + +const { SourceMapConsumer } = require('source-map'); + +function parseArgs(argv) { + const out = { positional: [], output: null }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '-o' || a === '--output') out.output = argv[++i]; + else if (a === '-h' || a === '--help') out.help = true; + else out.positional.push(a); + } + return out; +} + +function loadJson(p, label) { + if (!fs.existsSync(p)) { + console.error(`${label} not found: ${p}`); + process.exit(1); + } + try { + return JSON.parse(fs.readFileSync(p, 'utf8')); + } catch (e) { + console.error(`${label} is not valid JSON: ${p} (${e.message})`); + process.exit(1); + } +} + +// Normalize input to a V8 .cpuprofile shape so the output is always uniform. +// Mirrors analyze-cpuprofile.js's chromeTraceToV8Profile conversion. +function normalizeToV8(profile) { + if (!Array.isArray(profile)) return profile; + const profileEvent = profile.find((e) => e.name === 'Profile'); + const chunkEvents = profile.filter((e) => e.name === 'ProfileChunk'); + const startTime = profileEvent?.args?.data?.startTime || 0; + const nodesById = new Map(); + const samples = []; + const timeDeltas = []; + for (const c of chunkEvents) { + const d = c.args?.data || {}; + const chunkNodes = d.cpuProfile?.nodes || []; + const chunkSamples = d.cpuProfile?.samples || []; + const chunkDeltas = d.timeDeltas || []; + for (const n of chunkNodes) { + if (!nodesById.has(n.id)) { + const cf = { ...(n.callFrame || {}) }; + if (typeof cf.scriptId === 'number') cf.scriptId = String(cf.scriptId); + nodesById.set(n.id, { + id: n.id, + callFrame: cf, + parent: n.parent || null, + hitCount: 0, + children: [], + }); + } + } + for (const s of chunkSamples) samples.push(s); + for (const dt of chunkDeltas) timeDeltas.push(dt); + } + for (const node of nodesById.values()) { + if (node.parent != null) { + const p = nodesById.get(node.parent); + if (p) p.children.push(node.id); + } + } + const totalUs = timeDeltas.reduce((a, b) => a + b, 0); + return { + nodes: [...nodesById.values()], + samples, + timeDeltas, + startTime, + endTime: startTime + totalUs, + }; +} + +function isPseudoFrame(cf) { + const fn = cf.functionName || ''; + if (fn === '(root)' || fn === '(program)' || fn === '(idle)') return true; + if (fn === '(garbage collector)' || fn === '(gc)') return true; + return false; +} + +function rewriteFrames(profile, consumer) { + let rewritten = 0; + let unmapped = 0; + let skipped = 0; + for (const node of profile.nodes) { + const cf = node.callFrame; + if (!cf) { + skipped++; + continue; + } + if (isPseudoFrame(cf) || !cf.url) { + skipped++; + continue; + } + if (typeof cf.lineNumber !== 'number') { + skipped++; + continue; + } + // cpuprofile lineNumber/columnNumber are 0-indexed; + // source-map originalPositionFor expects 1-indexed line, 0-indexed column. + const orig = consumer.originalPositionFor({ + line: cf.lineNumber + 1, + column: cf.columnNumber || 0, + }); + if (!orig || orig.source == null) { + unmapped++; + continue; + } + cf.url = orig.source; + cf.lineNumber = (orig.line || 1) - 1; + cf.columnNumber = orig.column || 0; + if (orig.name) cf.functionName = orig.name; + rewritten++; + } + return { rewritten, unmapped, skipped }; +} + +function main() { + const opts = parseArgs(process.argv.slice(2)); + if (opts.help || opts.positional.length !== 2) { + console.log(` +Usage: + node perf/desymbolicate-cpuprofile.js [-o ] + +If -o is omitted, writes .desymbolicated.cpuprofile next to the input. +`); + process.exit(opts.help ? 0 : 1); + } + const [profilePath, mapPath] = opts.positional; + const outPath = + opts.output || profilePath.replace(/\.cpuprofile$/, '') + '.desymbolicated.cpuprofile'; + + console.log(`Loading profile: ${path.basename(profilePath)}`); + const raw = loadJson(profilePath, 'profile'); + const profile = normalizeToV8(raw); + if (!profile.nodes) { + console.error('Input does not look like a profile (no nodes after normalization).'); + process.exit(1); + } + console.log(` ${profile.nodes.length} call frames, ${profile.samples?.length || 0} samples`); + + console.log(`Loading source map: ${path.basename(mapPath)}`); + const rawMap = loadJson(mapPath, 'source map'); + const consumer = new SourceMapConsumer(rawMap); + + console.log('Rewriting frames ...'); + const { rewritten, unmapped, skipped } = rewriteFrames(profile, consumer); + consumer.destroy && consumer.destroy(); + + console.log(` rewritten: ${rewritten}`); + console.log(` unmapped: ${unmapped} (source map didn't cover the position)`); + console.log(` skipped: ${skipped} (pseudo-frames, builtins, missing line)`); + + fs.writeFileSync(outPath, JSON.stringify(profile)); + const sizeKb = (fs.statSync(outPath).size / 1024).toFixed(1); + console.log(`\nWrote ${sizeKb} KB → ${outPath}`); + console.log(`\nNext: node perf/analyze-cpuprofile.js ${outPath}`); +} + +main();