mcpField
C
cpu-profile-analysis

cpu-profile-analysis

github.com/microsoft开发194.4k166 次安装今天更新
代码仓库

关于 cpu-profile-analysis

用于分析 V8 `.cpuprofile` 和 DevTools `Trace-*.json` 的技能,帮助开发者定位性能瓶颈、比较代码路径并理解时间线。

主要功能

  1. 01解析 CPU 性能配置文件
  2. 02分析 DevTools 跟踪事件
  3. 03定位耗时函数与任务
  4. 04比较代码路径耗时
  5. 05检查布局、渲染与 GC

适用场景

  1. 01调查代码路径变慢原因
  2. 02比较新旧实现的 CPU 开销
  3. 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 用了多长时间”之类的问题:

  1. 找到栈上包含 X 标记的第一个非空闲采样
  2. 找到栈上包含 Y 标记的第一个采样
  3. 两者绝对时间戳之间的差值即为近似耗时
  4. 列出这两个时间点之间的所有非空闲采样,以查看这段时间内执行了哪些工作
6. 比较代码路径

比较两个实现时:

  1. 确定每个实现对应的活动区域
  2. 对每个区域计算各函数的自身耗时(归因于叶帧的时间)
  3. 按自身耗时降序排序,找出耗时最多的部分
  4. 分别展示每个区域中前 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.timelineRunTask、EvaluateScript、TracingStartedInBrowser——核心任务调度
devtools.timelineFunctionCall、EventDispatch、TimerInstall/Fire、PrePaint、Paint——主线程活动
blink.user_timingVS Code 性能标记(例如,code/willResolveTextFileEditorModel、code/didResolveTextFileEditorModel)
blink,devtools.timelineUpdateLayoutTree、HitTest、IntersectionObserver、ParseAuthorStyleSheet——布局/渲染
disabled-by-default-v8.cpu_profilerProfile、ProfileChunk——嵌入式 CPU 性能分析数据(与 .cpuprofile 相同,但分块存储)
v8v8.callFunction、v8.newInstance、V8.DeoptimizeCode——V8 引擎事件
v8,devtools.timelinev8.compile——脚本编译
devtools.timeline,v8MinorGC、MajorGC——垃圾回收
cppgcC++ GC 事件(Blink 垃圾回收)
loadingLayoutShift、URLLoader——资源加载和布局偏移
cc,benchmark,disabled-by-default-devtools.timeline.frame帧流水线事件(PipelineReporter、Commit 等)
__metadataprocess_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 或被抢占)。

评论

支持 Markdown