Svelte 性能回归调查实战:bench:compare 分支基准对比、CPU Profile 热点分析与 runtime-runes 回归验证
【免费下载链接】svelteweb development for the rest of us项目地址: https://gitcode.com/GitHub_Trending/sv/svelte
本文围绕 Svelte 官方仓库内置的性能调查工作流展开:如何用pnpm bench:compare对比两个分支的响应式基准成绩、去哪里查看原始数据与 CPU Profile、如何用 profile 差值工具定位热点函数变化,以及在修改性能相关代码后应运行哪些测试来防回归。读完本文,你可以独立完成"发现回归 → 定位热点 → 小步优化 → 验证无回归"的完整性能调查闭环。
一、性能调查的整体工作流
Svelte 仓库将性能基准设施放在benchmarking/目录下,核心技能文档为 .agents/skills/performance-investigation/SKILL.md。整个调查流程可以概括为:
- 从一个待测分支(例如
foo)出发; - 运行分支对比基准:
pnpm bench:compare main foo; - 在汇总报告里找到差距最大的基准项;
- 对高差距基准项,逐一对比两个分支的 CPU Profile 热点摘要;
- 一次只做一个优化改动,先跑定向基准,再跑完整对比;
- 性能改动后运行
pnpm test runtime-runes防止响应式语义回归。
所有脚本均定义在根目录 package.json 的 scripts 中:
{ "bench": "NODE_ENV=production node --allow-natives-syntax ./benchmarking/run.js", "bench:compare": "NODE_ENV=production node --allow-natives-syntax ./benchmarking/compare/index.js", "bench:debug": "NODE_ENV=production node --allow-natives-syntax --inspect-brk ./benchmarking/run.js", "test": "vitest run" }几个细节值得注意:
NODE_ENV=production让构建产物走生产路径,避免开发模式校验逻辑污染计时;--allow-natives-syntax开启 V8 原生语法,配合 benchmarking/utils.js 中通过v8.collectGarbage()主动触发垃圾回收的计时逻辑;- 仓库要求
pnpm >= 9.0.0(见 package.json 的engines字段),且基准脚本均为 ESM 写法("type": "module")。
二、快速上手:一条命令对比两个分支
在干净工作区中,从任意分支执行:
pnpm bench:compare main foo如果只传一个分支名,bench:compare会自动与main对比。这一行为在 benchmarking/compare/index.js 中有明确实现:
if ( requested_branches.length === 1 && !requested_branches.includes('main') && !fs.existsSync(`${outdir}/main.json`) ) { requested_branches.push('main'); }即:当只请求一个分支、且该分支不是main、且本地还没有main.json缓存结果时,脚本会自动把main追加为对比基线。另外若完全不带参数,脚本会用git symbolic-ref --short -q HEAD取当前分支名作为待测分支。
脚本的核心机制是"边跑边切分支":
- 记录当前
original_ref; - 依次
git checkout ${branch}切到每个待测分支; - 每个分支 fork 出 benchmarking/compare/runner.js 子进程执行全部基准,通过环境变量
BENCH_PROFILE_DIR告知 profile 输出目录; - 子进程把结果
send回父进程,父进程写入${outdir}/${branch}.json; - 进程退出时通过
process.on('exit')自动git checkout ${original_ref}还原分支。
因此官方文档特别强调一个实操陷阱:运行期间会切换分支,请先提交或 stash 未提交的改动,否则分支切换不安全。同时每次bench:compare运行都会清空并重写该分支的旧结果——脚本在跑之前会删除${outdir}/${branch}.json与${PROFILE_DIR}/${branch}/目录,所以产物永远对应当次运行。
三、基准数据从哪里读:产物目录与报告格式
一次bench:compare main foo运行后,产物落在两个隐藏目录中:
| 产物 | 路径 | 内容 |
|---|---|---|
| 汇总报告 | benchmarking/compare/.results/report.txt | 每个基准按分支列出 time / gc_time 条形对比 |
| 原始数值(基线) | benchmarking/compare/.results/main.json | 全部基准的time、gc_time数组 |
| 原始数值(待测分支) | benchmarking/compare/.results/<your-branch>.json | 同上 |
| CPU Profile 原始文件 | benchmarking/compare/.profiles/main/*.cpuprofile | V8 采样剖析 JSON |
| CPU Profile 热点摘要 | benchmarking/compare/.profiles/main/*.md | 由 profile 自动生成的 Markdown 热点表 |
| 待测分支 profile | benchmarking/compare/.profiles/<your-branch>/*.cpuprofile与*.md | 同上 |
其中.md文件是 CPU Profile 的机器生成摘要,通常是最快的热点检查方式——无需打开 Chrome DevTools 即可直接看到热点函数排名。这些.md由 benchmarking/utils.js 中的profile_to_markdown()生成:它把 profile 的调用树节点按 self/inclusive 采样数统计,只保留(idle)、(garbage collector)以及 URL 落在packages/svelte/源码下的节点(is_special_runtime_node/is_svelte_source_url两个判断),按 inclusive 采样降序取前 25 条输出 "Top hotspots" 表,并附一张含 Function、URL、Line、Hit count、Deopt reason 的完整 "Nodes" 表——deopt reason 一列对定位 JIT 反优化尤其有用。
report.txt由 benchmarking/compare/generate-report.js 生成,格式如下(分支按字母序标注为a、b…):
a: main b: foo kairo_mux_owned time: fastest is b (foo) a: ◼◼◼◼◼ 12.34ms b: ◼◼◼◼◼◼◼ 18.90ms gc_time: fastest is a (main) ...该脚本有三个工程细节值得关注:
- 按基准名配对而非数组下标:两个分支的基准列表可能不一致(某基准只存在于其中一个分支),按下标配对会错配结果,所以报告按 benchmark 名称建立 Map 对齐,缺失的分支标注
skipped (missing on ...); - 双指标:每个基准同时对比
time(墙钟耗时)与gc_time(观测到的 GC 耗时,来自PerformanceObserver的 gc 条目),性能改动除了看耗时,还应看是否引入更多垃圾; - 附带 HTML 报告:同一数据还会注入 benchmarking/compare/results.template.html 的
%%REPORT_DATA%%占位符,生成benchmarking/compare/results.html可视化页面。
四、计时口径:每个基准如何测出 time 与 gc_time
理解报告数字的前提是理解采样方法。计时核心在 benchmarking/utils.js:
async function track(fn) { v8.collectGarbage(); // PerformanceObserver 观测 entryTypes: ['gc'] const start = performance.now(); fn(); const end = performance.now(); // 过滤 startTime 落在 [start, end) 内的 gc 条目,累加 duration return { time: end - start, gc_time }; } export async function fastest_test(times, fn) { // 循环 times 次,每次先 collectGarbage 再计时 return results.reduce((a, b) => (a.time < b.time ? a : b)); // 取最快一次 }即:每轮先强制 GC 清空场地,用PerformanceObserver捕获窗口期内的全部 GC 耗时,最终取多次运行中的最快值以压低噪声。
具体到每个响应式基准的执行体,benchmarking/benchmarks/reactivity/util.js 的create_test()为每个用例生成_owned与_unowned两个变体:
- 预热:先循环 10 次
setup() → run(0) → destroy(),让 JIT 达到稳态; - 正式计时:
fastest_test(10, ...),单次内部再循环 1000 次run(i); - owned 变体:用
$.effect_root(...)把 setup 包进一个响应式根,模拟真实组件树内的信号生命周期; - unowned 变体:直接裸跑,测纯信号机制的开销。
基准清单在 benchmarking/benchmarks/reactivity/index.js 中组装:先是 10 个sbench_create_*拓扑基准(sbench_create_signals、sbench_create_1to1、sbench_create_1to1000等,文件头注释说明其改造自 js-reactivity-benchmark),然后动态扫描tests/目录下所有*.bench.js(如kairo_mux.bench.js、kairo_deep.bench.js、mol.bench.js、repeated_deps.bench.js、clean_effects.bench.js),每个自动注册_owned/_unowned两条。这就是为什么基准名里到处是kairo_mux_owned这类后缀。
此外,benchmarking/compare/runner.js(以及 benchmarking/run.js)采用"父进程 fork 子进程"的模式跑每一个基准,代码注释写明原因:run every benchmark in its own child process, so that heap/GC/JIT state from one benchmark cannot contaminate the others。也就是说每个基准都在全新进程里跑,heap/GC/JIT 状态互不污染——这是跨分支数字可比性的基础。
五、快速迭代:pnpm bench 与子串过滤
完整bench:compare要切分支、跑全部基准,一轮耗时较长。小步优化时应先用pnpm bench只跑选定的响应式基准,参数按子串匹配基准 label:
# 只跑 kairo 拓扑系列 pnpm bench kairo_mux kairo_deep kairo_broad kairo_triangle # 或混合挑选 pnpm bench repeated_deps sbench_create_signals mol_owned对应实现在 benchmarking/run.js:const filters = process.argv.slice(2),然后对响应式与 SSR 两个 suite 分别过滤filters.some((f) => b.label.includes(f));没有任何匹配时打印No benchmarks matched provided filters并以码 1 退出。匹配成功时输出对齐的三列表格(Benchmark / Time / GC time)并附 suite 与 total 汇总行,profile 单独写入./benchmarking/.profiles。
推荐节奏是:改一行代码 →pnpm bench子串验证 → 满意后再pnpm bench:compare全量复核,避免全量对比被反复切换分支的成本拖慢迭代。
六、CPU Profile 差值工具:profile-diff.mjs
对于高差距基准项,最快的定位手段是 benchmarking/compare/profile-diff.mjs——直接打印两个分支之间 top 函数自采样份额差:
node benchmarking/compare/profile-diff.mjs kairo_mux_owned main foo输出按|delta|降序取前 20 行,形如:
Benchmark: kairo_mux_owned Base: main Candidate: foo +12.34pp candidate 25.10% base 2.76% $derived @ packages/svelte/src/internal/client/reactivity/deriveds.js:xx脚本的算法(见源码read_profile与主循环):
- 读取
.profiles/<branch>/<bench>.cpuprofile,遍历samples累加每个 node id 的 self 计数; - 用
callFrame.functionName+ 归一化后的 URL(去掉file://...packages/前缀并加上 1 的行号)拼出函数标签,把同名函数跨 node 合并; - 对并集上的每个函数计算
candidate% - base%,按绝对差排序输出前 20 名。
它回答的问题非常聚焦:这个函数在候选分支中多占(或少占)了多少样本份额。配合 benchmarking/utils.js 生成的.md热点表(看单分支的 Top hotspots 与 deopt reason),就覆盖了"谁变了"与"变得多严重"两个维度。
七、热点该往哪里看:响应式运行时内部模块
技能文档给出的排查重点是运行时内部热点份额(self/inclusive)的变化,具体落在以下四个文件:
- packages/svelte/src/internal/client/runtime.js:客户端运行时入口,组件执行、信号调度等核心逻辑所在;
- packages/svelte/src/internal/client/reactivity/batch.js:批量更新/脏传播调度,一次状态变更波及多少个派生就体现在这里;
- packages/svelte/src/internal/client/reactivity/deriveds.js:
$derived求值与失效标记; - packages/svelte/src/internal/client/reactivity/sources.js:
$state底层 source 的读写路径。
同目录下的 packages/svelte/src/internal/client/reactivity/ 还包含effects.js($effect与effect_root,正是基准owned变体用到的 API)、props.js、store.js等模块。当 profile 差值显示某个函数在reactivity/*.js中份额上升时,通常意味着脏传播路径变长、派生重复求值或批处理合并效率下降;显示在runtime.js时则更可能是组件更新、DOM 写入或循环调度的变化。由于profile_to_markdown只保留packages/svelte/下的节点,热点表天然聚焦在本仓库源码,第三方框架噪声已被过滤。
八、性能改动后的回归验证
响应式运行时的性能改动最容易破坏的是响应式语义本身,官方推荐的验证命令是:
pnpm test runtime-runes这对应 packages/svelte/tests/runtime-runes/test.ts 这一整套 runes 运行时测试(该目录包含上千个.svelte/.js样例用例,覆盖$state/$derived/$effect/$props的响应式、effect 执行顺序、cleanup 时序等语义)。pnpm test实际执行vitest run,带子路径参数即只跑该 suite。建议把"性能改动 →pnpm bench子串验证 →pnpm test runtime-runes全绿"作为固定收尾流程:基准数字变好而 runes 测试挂掉,说明优化以破坏语义为代价,必须回滚或重写。
九、实操注意事项(Practical gotchas)
汇总技能文档与源码中确认过的注意点:
- 分支切换风险:
bench:compare运行期间会git checkout各分支,结束前请确保工作区干净(提交或 stash),否则切换分支可能丢失或冲突未提交改动。脚本退出时会自动还原到运行前的 ref,但你无法控制中途切换时的本地状态。 - 产物会被整目录重写:每次
bench:compare都会先删除并重写benchmarking/compare/.results/<branch>.json与benchmarking/compare/.profiles/<branch>/,所以对比历史结果需要自己保存上一轮产物;两个目录为隐藏目录(.results、.profiles),默认ls看不到。 - 分支名会做安全化:写入 profile 目录前,分支名会经
safe()处理(非[a-z0-9._-]字符替换为_),含/等符号的远程分支引用在产物路径中会变成下划线形式。 - 计时取"最快一次":
fastest_test取 10 轮中的最小值,机器上有噪声(后台任务、频率调节)时单轮数字可能偏低;跨分支对比时两边口径一致,相对比较仍可信,但不要对绝对值做过度解读。 - profile 依赖 Node inspector:
with_cpu_profile通过node:inspector/promises的Profiler.start/stop采样,BENCH_PROFILE_DIR为 null 时(如不带对比目录的纯pnpm bench场景之外的普通运行)会直接跳过采集,不产生 profile。
附:一条完整的性能调查命令序列
# 0. 确保工作区干净 git status # 1. 全量对比当前特性分支与 main pnpm bench:compare main foo # 2. 看汇总,找最大回归项(假设为 kairo_mux_owned) cat benchmarking/compare/.results/report.txt # 3. 函数级热点差值 node benchmarking/compare/profile-diff.mjs kairo_mux_owned main foo # 4. 细看单分支热点表与反优化原因 cat benchmarking/compare/.profiles/foo/kairo_mux_owned.md # 5. 修改 packages/svelte/src/internal/client/reactivity/... 中对应逻辑 # 6. 快速迭代验证 pnpm bench kairo_mux kairo_deep # 7. 语义回归兜底 pnpm test runtime-runes以上全部能力均来自当前仓库自带工具链(benchmarking/ 目录),无需任何额外安装;理解其计时口径、进程隔离与 profile 过滤规则后,你对分支间基准数字与热点差值的判断会更有依据。
【免费下载链接】svelteweb development for the rest of us项目地址: https://gitcode.com/GitHub_Trending/sv/svelte
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考