关于 cpu-profile-analysis
用于分析 V8 `.cpuprofile` 和 DevTools `Trace-*.json` 的技能,帮助开发者定位性能瓶颈、比较代码路径并理解时间线。
主要功能
- 01解析 CPU 性能配置文件
- 02分析 DevTools 跟踪事件
- 03定位耗时函数与任务
- 04比较代码路径耗时
- 05检查布局、渲染与 GC
适用场景
- 01调查代码路径变慢原因
- 02比较新旧实现的 CPU 开销
- 03分析长任务与渲染瓶颈
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ microsoft/ vscode --skill cpu-profile-analysis安装后在 Claude Code 中输入/cpu-profile-analysis确认已加载。
详细介绍
分析性能配置文件
分析 .cpuprofile 文件(V8 采样分析器)和 DevTools 跟踪文件(Trace-*.json,Chrome Trace Event Format),以查找性能瓶颈、比较代码路径并了解耗时情况。
使用场景
- 用户提供了
.cpuprofile或Trace-*.json文件,并希望了解性能情况 - 调查某条代码路径比另一条慢的原因
- 查找哪些函数消耗的时间最多
- 在单个配置文件中比较“之前/之后”或“旧/新”实现
- 调查布局抖动、长任务或渲染瓶颈(跟踪文件)
- 分析 VS Code 用户计时标记,例如
code/didResolveTextFileEditorModel(跟踪文件) - 了解多进程行为(跟踪文件中的 Browser、Renderer、GPU 进程)
检测文件类型
.cpuprofile:顶层 JSON 包含nodes、samples、timeDeltas键。由 VS Code 分析器创建。Trace-*.json:顶层 JSON 包含traceEvents数组(以及可选的metadata)。由 Chrome/Electron DevTools(Performance 选项卡)创建。这类文件比.cpuprofile包含更丰富的信息——包括 CPU 采样、布局/绘制事件、用户计时标记、GC 事件、输入事件和多进程数据。
关键概念
- 采样分析器:分析器会定期对调用栈进行快照。并非每个函数都会出现——只有在分析器采样时位于栈上的函数才会出现。不要指望看到确切的函数名;应寻找模式和附近的活动。
- 自身耗时:函数本身(叶帧/最内层帧)所花费的时间。
- 总耗时:函数位于栈上任意位置时所花费的时间(包括被调用函数)。
- 空闲采样:标记为
(idle)、(program)或(garbage collector)的帧表示没有用户代码在运行。
第 1 部分:.cpuprofile 文件
配置文件格式
.cpuprofile 是包含以下顶层键的 JSON:
nodes:构成树的调用帧节点数组(每个节点都有id、callFrame、children)samples:节点 ID 数组——每个分析器时钟周期对应一个,引用叶帧(最内层帧)timeDeltas:连续采样之间的微秒差值数组startTime/endTime:以微秒为单位的绝对时间戳$vscode:可选的 VS Code 元数据
操作步骤
1. 检查文件大小并解析
配置文件和跟踪文件可能会超过 V8 的字符串长度限制(约 512 MB)。务必先检查文件大小,并选择合适的解析策略:
import { readFileSync, statSync } from 'fs';
const stat = statSync(profilePath);
const sizeMB = stat.size / (1024 * 1024);
console.log(`File size: ${sizeMB.toFixed(0)}MB`);
let data;
if (sizeMB < 400) {
// Small enough for JSON.parse
data = JSON.parse(readFileSync(profilePath, 'utf8'));
} else {
// Too large -- use Buffer-based extraction (see "Handling Huge Files" section)
data = parseProfileFromBuffer(readFileSync(profilePath));
}
对于小于约 400 MB 的文件,使用 JSON.parse(readFileSync(..., 'utf8')) 即可正常处理。对于更大的文件,请参阅下文的处理超大文件部分。
2. 重新格式化文件(仅限小文件)
配置文件通常是单行 JSON。为了便于检查,可重新格式化(仅当文件足够小时):
if (sizeMB < 400) {
const data = JSON.parse(fs.readFileSync(profilePath, 'utf8'));
fs.writeFileSync(profilePath, JSON.stringify(data, null, 2));
}
3. 构建数据结构
编写 Node.js 分析脚本。构建以下数据结构:
// Node lookup
const nodeMap = new Map(); // id -> node
const parentMap = new Map(); // id -> parent id
// Absolute timestamps from deltas
const timestamps = [data.startTime];
for (let i = 0; i < data.timeDeltas.length; i++) {
timestamps.push(timestamps[i] + data.timeDeltas[i]);
}
// Stack walker (leaf to root)
function getStack(sampleNodeId) {
const stack = [];
let id = sampleNodeId;
while (id !== undefined) {
const node = nodeMap.get(id);
if (node) stack.push(node.callFrame.functionName);
id = parentMap.get(id);
}
return stack; // [leaf, ..., root]
}
4. 识别活动区域
将时间线拆分为多个时间段(例如每段 500 ms),并找出其中包含相关函数名的时间段。使用与用户问题相关的标记函数来检测活动时间窗口。合并区域时,允许存在小间隔(1–2 个空时间段)。
重要:由于这是采样分析器,不要要求函数名完全匹配。应使用相关标记函数集合,并关注更广泛的执行流程。
5. 测量里程碑之间的耗时
对于“从 X 到 Y 用了多长时间”之类的问题:
- 找到栈上包含 X 标记的第一个非空闲采样
- 找到栈上包含 Y 标记的第一个采样
- 两者绝对时间戳之间的差值即为近似耗时
- 列出这两个时间点之间的所有非空闲采样,以查看这段时间内执行了哪些工作
6. 比较代码路径
比较两个实现时:
- 确定每个实现对应的活动区域
- 对每个区域计算各函数的自身耗时(归因于叶帧的时间)
- 按自身耗时降序排序,找出耗时最多的部分
- 分别展示每个区域中前 N 个非空闲调用栈,以呈现启动顺序
7. 报告发现
按以下形式呈现结果:
- 时间线:每个活动区域相对于性能分析开始时间的发生时间
- 持续时间:每个区域持续了多久
- 按自身耗时排列的顶级函数:CPU 时间实际花在了哪里
- 对比表:对比路径时并排列出指标
- 堆栈跟踪:展示关键路径的关键采样堆栈
第 2 部分:DevTools 跟踪文件(Trace-*.json)
DevTools 跟踪是 VS Code 性能跟踪的未来方向。它们由内置的 Electron/Chrome DevTools Performance 选项卡创建,包含的信息远多于 .cpuprofile 文件。
跟踪格式
Trace-*.json 文件包含以下顶层键:
traceEvents:跟踪事件对象数组(数十万条)metadata:包含source、startTime、dataOrigin和可选 DevTools 状态(面包屑、注释)的对象
跟踪事件结构
traceEvents 中的每个事件都遵循 Chrome Trace Event Format:
{
"pid": 3406, // Process ID
"tid": 7534980, // Thread ID
"ts": 200420830729, // Timestamp in microseconds
"ph": "X", // Phase (event type)
"cat": "devtools.timeline", // Category
"name": "EventDispatch", // Event name
"dur": 9, // Duration in microseconds (for complete events)
"tdur": 8, // Thread duration (excludes time thread was suspended)
"args": { ... }, // Event-specific arguments
"tts": 7078808 // Thread timestamp
}
阶段类型(ph)
| 阶段 | 名称 | 含义 |
|---|---|---|
X | 完成 | 带有持续时间(dur 字段)的事件。最常见。 |
B | 开始 | 持续时间事件的开始(与 E 配对)。 |
E | 结束 | 持续时间事件的结束(与 B 配对)。 |
I | 即时 | 瞬时事件(无持续时间)。 |
P | 采样 | CPU 性能分析器采样。 |
R | 标记 | 导航计时标记。 |
M | 元数据 | 进程/线程名称元数据。 |
N | 对象已创建 | 对象生命周期跟踪。 |
D | 对象已销毁 | 对象生命周期跟踪。 |
s | 流开始 | 异步流连接开始。 |
f | 流结束 | 异步流连接结束。 |
b | 异步开始 | 异步事件开始。 |
e | 异步结束 | 异步事件结束。 |
n | 异步即时 | 异步事件即时发生。 |
关键类别及其内容
| 类别 | 捕获内容 |
|---|---|
disabled-by-default-devtools.timeline | RunTask、EvaluateScript、TracingStartedInBrowser——核心任务调度 |
devtools.timeline | FunctionCall、EventDispatch、TimerInstall/Fire、PrePaint、Paint——主线程活动 |
blink.user_timing | VS Code 性能标记(例如,code/willResolveTextFileEditorModel、code/didResolveTextFileEditorModel) |
blink,devtools.timeline | UpdateLayoutTree、HitTest、IntersectionObserver、ParseAuthorStyleSheet——布局/渲染 |
disabled-by-default-v8.cpu_profiler | Profile、ProfileChunk——嵌入式 CPU 性能分析数据(与 .cpuprofile 相同,但分块存储) |
v8 | v8.callFunction、v8.newInstance、V8.DeoptimizeCode——V8 引擎事件 |
v8,devtools.timeline | v8.compile——脚本编译 |
devtools.timeline,v8 | MinorGC、MajorGC——垃圾回收 |
cppgc | C++ GC 事件(Blink 垃圾回收) |
loading | LayoutShift、URLLoader——资源加载和布局偏移 |
cc,benchmark,disabled-by-default-devtools.timeline.frame | 帧流水线事件(PipelineReporter、Commit 等) |
__metadata | process_name、thread_name——进程/线程标识 |
进程和线程
跟踪文件包含来自多个进程的事件:
| 进程 | 角色 | 关键线程 |
|---|---|---|
| 渲染器(pid 不定) | VS Code 的渲染进程——运行 JavaScript 的位置 | CrRendererMain(主线程) |
| 浏览器(pid 不定) | Electron 的主进程/浏览器进程 | CrBrowserMain |
| GPU 进程(pid 不定) | GPU 合成和渲染 | CrGpuMain、VizCompositorThread |
通过元数据事件识别进程/线程:
const procNames = events.filter(e => e.name === 'process_name');
// => [{args: {name: 'Renderer'}, pid: 3406}, {args: {name: 'Browser'}, pid: 3348}, ...]
const threadNames = events.filter(e => e.name === 'thread_name');
// => [{args: {name: 'CrRendererMain'}, pid: 3406, tid: 7534980}, ...]
对于 VS Code 性能分析,请重点关注渲染器进程的 CrRendererMain 线程——JavaScript 执行、布局和绘制都在此处进行。
操作步骤
1. 检查文件大小并解析
跟踪文件通常为 50-200MB,但可能超过 V8 的字符串长度限制(约 512MB)。请务必先检查:
import { readFileSync, statSync } from 'fs';
const stat = statSync(tracePath);
const sizeMB = stat.size / (1024 * 1024);
console.log(`File size: ${sizeMB.toFixed(0)}MB`);
let data;
if (sizeMB < 400) {
data = JSON.parse(readFileSync(tracePath, 'utf8'));
} else {
// Too large -- use Buffer-based extraction (see "Handling Huge Files" section)
data = parseTraceFromBuffer(readFileSync(tracePath));
}
const events = data.traceEvents;
2. 重新格式化文件(仅适用于小文件)
对于较小的跟踪文件,可重新格式化以便检查:
if (sizeMB < 400) {
fs.writeFileSync(tracePath, JSON.stringify(data, null, 2));
}
3. 构建数据结构
const data = JSON.parse(fs.readFileSync(tracePath, 'utf8'));
const events = data.traceEvents;
// Identify Renderer main thread
const rendererPid = events.find(e => e.name === 'process_name' && e.args?.name === 'Renderer')?.pid;
const mainTid = events.find(e => e.name === 'thread_name' && e.pid === rendererPid && e.args?.name === 'CrRendererMain')?.tid;
// Filter to main thread events for most analysis
const mainEvents = events.filter(e => e.pid === rendererPid && e.tid === mainTid);
4. 分析用户计时标记
VS Code 会发出 performance.mark() 调用,这些调用会显示为 blink.user_timing 事件。这是测量 VS Code 特定里程碑最直接的方式:
const userTimings = events.filter(e => e.cat?.includes('blink.user_timing') && !e.cat.includes('rail'));
// Each has: name (e.g. 'code/didResolveTextFileEditorModel'), ts (microseconds), args.data.startTime (ms from navigation)
5. 分析长任务
查找主线程上的高开销任务:
const longTasks = mainEvents
.filter(e => e.name === 'RunTask' && e.ph === 'X' && e.dur > 50000) // > 50ms
.sort((a, b) => b.dur - a.dur);
6. 分析函数调用
FunctionCall 事件包含源代码位置信息:
const funcCalls = mainEvents
.filter(e => e.name === 'FunctionCall' && e.dur > 10000) // > 10ms
.sort((a, b) => b.dur - a.dur);
// args.data contains: functionName, url, lineNumber, columnNumber, scriptId
7. 分析布局和渲染
查找布局抖动和高开销绘制:
const layoutEvents = mainEvents.filter(e =>
e.name === 'UpdateLayoutTree' || e.name === 'Layout' ||
e.name === 'PrePaint' || e.name === 'Paint'
);
// UpdateLayoutTree.args.elementCount tells you how many elements were restyled
8. 提取嵌入的 CPU 性能分析数据
跟踪文件包含完整的 CPU 性能分析数据,形式为 ProfileChunk 事件。将其重建:
const profileEvent = events.find(e => e.name === 'Profile' && e.pid === rendererPid);
const chunks = events.filter(e => e.name === 'ProfileChunk' && e.pid === rendererPid && e.id === profileEvent.id);
// Each chunk's args.data.cpuProfile contains: {nodes: [...], samples: [...]}
// Each chunk's args.data.timeDeltas contains sample timing
// Merge all chunks to reconstruct a full cpuprofile-like structure
const allNodes = [];
const allSamples = [];
const allDeltas = [];
for (const chunk of chunks) {
const cp = chunk.args.data.cpuProfile;
if (cp.nodes) allNodes.push(...cp.nodes);
if (cp.samples) allSamples.push(...cp.samples);
if (chunk.args.data.timeDeltas) allDeltas.push(...chunk.args.data.timeDeltas);
}
// Now analyze allNodes/allSamples/allDeltas using the same approach as .cpuprofile
9. 分析 GC 压力
const gcEvents = mainEvents.filter(e => e.name === 'MinorGC' || e.name === 'MajorGC');
const totalGcTime = gcEvents.reduce((sum, e) => sum + (e.dur || 0), 0);
// Also check cppgc events for Blink GC
const cppgcEvents = events.filter(e => e.cat?.includes('cppgc'));
10. 分析输入延迟
const dispatches = mainEvents.filter(e => e.name === 'EventDispatch');
// args.data.type tells you the event type: 'click', 'keydown', 'mousedown', etc.
// dur tells you how long the handler took
const longHandlers = dispatches.filter(e => e.dur > 50000).sort((a, b) => b.dur - a.dur);
11. 报告发现
按以下方式呈现结果:
- 时间线:每个活动区段相对于跟踪开始时间的发生时刻
- 用户计时标记:VS Code 里程碑事件及其时间戳
- 长任务:阻塞主线程的 > 50ms 任务
- 按持续时间排序的顶级函数:CPU 时间的去向及源代码位置
- 布局/渲染:高开销的样式重新计算和绘制
- GC 压力:GC 总耗时和频率
- 输入延迟:导致响应变慢的事件处理程序
- 进程分解:Browser、Renderer 和 GPU 中发生的工作
处理超大文件
当 .cpuprofile 或 Trace-*.json 文件超过约 400MB 时,readFileSync(..., 'utf8') 可能会失败,因为 V8 无法创建这么大的字符串。请改用基于 Buffer 的提取方式:以原始 Buffer 读取文件,并通过扫描已知 JSON 键来提取片段。堆快照也使用了相同的技术(参见 parseSnapshot.ts)。
关键原则:将文件读取为 Buffer,通过扫描字节定位 JSON 数组/对象的边界,提取足够小、可供 JSON.parse 处理的独立片段作为子 Buffer,然后组装结果。
始终使用额外内存运行分析脚本:node --max-old-space-size=16384 script.mjs
.cpuprofile 的基于 Buffer 的解析
.cpuprofile 的顶层键包括 nodes、samples、timeDeltas、startTime 和 endTime。从 Buffer 中提取每个片段:
import { readFileSync, statSync } from 'fs';
function parseProfileFromBuffer(buf) {
// Helper: find the array value for a given key, return parsed array
function extractArray(key) {
const keyBuf = Buffer.from(`"${key}":[`);
const pos = buf.indexOf(keyBuf);
if (pos === -1) throw new Error(`${key} not found`);
const arrayStart = pos + keyBuf.length;
// Find matching ']' -- arrays of numbers have no nested brackets
const arrayEnd = buf.indexOf(0x5D, arrayStart); // 0x5D = ']'
return JSON.parse('[' + buf.subarray(arrayStart, arrayEnd).toString('utf8') + ']');
}
// Helper: find a scalar value for a given key
function extractScalar(key) {
const keyBuf = Buffer.from(`"${key}":`);
const pos = buf.indexOf(keyBuf);
if (pos === -1) throw new Error(`${key} not found`);
const valueStart = pos + keyBuf.length;
// Scan to next comma or closing brace
let end = valueStart;
while (end < buf.length && buf[end] !== 0x2C && buf[end] !== 0x7D) end++;
return JSON.parse(buf.subarray(valueStart, end).toString('utf8'));
}
// Extract the nodes array -- contains objects, so we need bracket matching
function extractNodesArray() {
const keyBuf = Buffer.from('"nodes":[');
const pos = buf.indexOf(keyBuf);
if (pos === -1) throw new Error('nodes not found');
const start = pos + keyBuf.length - 1; // include '['
let depth = 0, end = -1;
for (let i = start; i < buf.length; i++) {
if (buf[i] === 0x5B) depth++;
else if (buf[i] === 0x5D) { depth--; if (depth === 0) { end = i + 1; break; } }
// Skip strings to avoid counting brackets inside them
if (buf[i] === 0x22) { i++; while (i < buf.length) { if (buf[i] === 0x5C) i++; else if (buf[i] === 0x22) break; i++; } }
}
if (end === -1) throw new Error('nodes array end not found');
return JSON.parse(buf.subarray(start, end).toString('utf8'));
}
const nodes = extractNodesArray();
const samples = extractArray('samples');
const timeDeltas = extractArray('timeDeltas');
const startTime = extractScalar('startTime');
const endTime = extractScalar('endTime');
return { nodes, samples, timeDeltas, startTime, endTime };
}
Trace-*.json 的基于 Buffer 的解析
跟踪文件有两个顶层键:metadata(小型对象)和 traceEvents(包含大量对象的数组)。策略是正常提取 metadata,并通过扫描各个事件对象来流式解析 traceEvents:
import { readFileSync } from 'fs';
function parseTraceFromBuffer(buf) {
// 1. Extract metadata (small, near the top of the file)
let metadata = {};
const metaKeyBuf = Buffer.from('"metadata":{');
const metaPos = buf.indexOf(metaKeyBuf);
if (metaPos !== -1) {
const metaStart = metaPos + metaKeyBuf.length - 1; // include '{'
let depth = 0, metaEnd = -1;
for (let i = metaStart; i < buf.length; i++) {
if (buf[i] === 0x7B) depth++;
else if (buf[i] === 0x7D) { depth--; if (depth === 0) { metaEnd = i + 1; break; } }
if (buf[i] === 0x22) { i++; while (i < buf.length) { if (buf[i] === 0x5C) i++; else if (buf[i] === 0x22) break; i++; } }
}
if (metaEnd !== -1) {
metadata = JSON.parse(buf.subarray(metaStart, metaEnd).toString('utf8'));
}
}
// 2. Extract traceEvents by parsing in chunks
// Each event is a JSON object {...}. Scan for object boundaries.
const eventsKeyBuf = Buffer.from('"traceEvents":[');
const eventsPos = buf.indexOf(eventsKeyBuf);
if (eventsPos === -1) throw new Error('traceEvents not found');
const arrayStart = eventsPos + eventsKeyBuf.length;
const traceEvents = [];
let i = arrayStart;
while (i < buf.length) {
// Skip whitespace and commas
while (i < buf.length && (buf[i] === 0x20 || buf[i] === 0x0A || buf[i] === 0x0D || buf[i] === 0x09 || buf[i] === 0x2C)) i++;
if (i >= buf.length || buf[i] === 0x5D) break; // end of array
if (buf[i] !== 0x7B) { i++; continue; } // expect '{'
// Find matching '}'
let depth = 0, objEnd = -1;
for (let j = i; j < buf.length; j++) {
if (buf[j] === 0x7B) depth++;
else if (buf[j] === 0x7D) { depth--; if (depth === 0) { objEnd = j + 1; break; } }
if (buf[j] === 0x22) { j++; while (j < buf.length) { if (buf[j] === 0x5C) j++; else if (buf[j] === 0x22) break; j++; } }
}
if (objEnd === -1) break;
// Parse this individual event object -- each is small enough for JSON.parse
traceEvents.push(JSON.parse(buf.subarray(i, objEnd).toString('utf8')));
i = objEnd;
}
return { metadata, traceEvents };
}
何时使用 Buffer 解析
| 文件大小 | 处理方式 |
|---|---|
| < 400MB | 使用 JSON.parse(readFileSync(path, 'utf8')) 即可 |
| 400MB - 1GB | 使用上述基于 Buffer 的提取函数 |
| > 1GB | 使用基于 Buffer 的提取方式 + --max-old-space-size=16384 |
大文件分析技巧
- 始终使用
node --max-old-space-size=16384运行,以为 Node.js 提供足够的堆空间。 - 对于跟踪文件,可考虑在解析时筛选事件(例如,跳过不需要的类别)以减少内存占用。
- Buffer 方式会将整个文件作为字节读入内存(这没有问题,因为 Buffer 不受约 512MB 的字符串大小限制)。单次
JSON.parse调用只处理较小的子 Buffer。 - 如果重新格式化后会生成过大的文件,无法写回,请跳过重新格式化,直接处理解析后的数据结构。
- 如果只需要超大跟踪中的特定事件类型,可在解析循环中添加筛选回调,避免为之后会丢弃的事件分配对象。
提示
- 两种格式中的时间戳单位都是微秒。除以 1000 即为毫秒。
- 对于打包/压缩后的代码(例如
extension.js),函数名可能已被混淆。请使用callFrame.lineNumber中的行号与源映射交叉引用。 - 测量活跃 CPU 工作时,请过滤掉空闲、程序和 GC 样本。
- 当用户询问某段时间间隔时,请确认它是否确实处于空闲状态(事件循环正在等待),还是在执行无关代码中的活跃工作。
- 完成后,请清理你创建的所有分析脚本。
- 跟踪文件很大(50-200MB)。请始终先过滤到相关进程/线程,再进行分析,以减少内存占用和噪声。
- 当跟踪文件包含嵌入的
ProfileChunk事件时,优先分析这些事件,而不是要求提供单独的.cpuprofile——其中的数据等效,但已与其他跟踪事件关联。 - 使用
FunctionCall和EvaluateScript事件中的args.data.url,映射回 VS Code 源文件(路径类似vscode-file://vscode-app/Users/.../out/vs/...)。 dur字段表示墙钟时间;tdur字段表示线程时间。两者之差可揭示线程被挂起的时间(例如等待 I/O 或被抢占)。