diff --git a/.agents/skills/performance-investigation/SKILL.md b/.agents/skills/performance-investigation/SKILL.md new file mode 100644 index 0000000000..cbc5d81882 --- /dev/null +++ b/.agents/skills/performance-investigation/SKILL.md @@ -0,0 +1,70 @@ +--- +name: performance-investigation +description: Investigate performance regressions and find opportunities for optimization +--- + +## Quick start + +1. Start from a branch you want to measure (for example `foo`). +2. Run: + +```sh +pnpm bench:compare main foo +``` + +If you pass one branch, `bench:compare` automatically compares it to `main`. + +## Where outputs go + +- Summary report: `benchmarking/compare/.results/report.txt` +- Raw benchmark numbers: + - `benchmarking/compare/.results/main.json` + - `benchmarking/compare/.results/.json` +- CPU profiles (per benchmark, per branch): + - `benchmarking/compare/.profiles/main/*.cpuprofile` + - `benchmarking/compare/.profiles/main/*.md` + - `benchmarking/compare/.profiles//*.cpuprofile` + - `benchmarking/compare/.profiles//*.md` + +The `.md` files are generated summaries of the CPU profile and are usually the fastest way to inspect hotspots. + +## Suggested investigation flow + +1. Open `benchmarking/compare/.results/report.txt` and identify largest regressions first. +2. For each high-delta benchmark, compare: + - `benchmarking/compare/.profiles/main/.md` + - `benchmarking/compare/.profiles//.md` +3. Look for changes in self/inclusive hotspot share in runtime internals (`runtime.js`, `reactivity/batch.js`, `reactivity/deriveds.js`, `reactivity/sources.js`). +4. Make one optimization change at a time, then re-run targeted benches before re-running full compare. + +## Fast benchmark loops + +Run only selected reactivity benchmarks by substring: + +```sh +pnpm bench kairo_mux kairo_deep kairo_broad kairo_triangle +pnpm bench repeated_deps sbench_create_signals mol_owned +``` + +## Tests to run after perf changes + +Runtime reactivity regressions are most likely in runes runtime tests: + +```sh +pnpm test runtime-runes +``` + +## Helpful script + +For quick cpuprofile hotspot deltas between two branches: + +```sh +node benchmarking/compare/profile-diff.mjs kairo_mux_owned main foo +``` + +This prints top function sample-share deltas for the selected benchmark. + +## Practical gotchas + +- `bench:compare` checks out branches while running. Avoid uncommitted changes (or stash them) so branch switching is safe. +- Each `bench:compare` run rewrites `benchmarking/compare/.results` and `benchmarking/compare/.profiles`. diff --git a/.changeset/poor-tips-send.md b/.changeset/poor-tips-send.md new file mode 100644 index 0000000000..beb0329705 --- /dev/null +++ b/.changeset/poor-tips-send.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: never set derived.v inside fork diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..c6cd3ea310 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,9 @@ +# Svelte Coding Agent Guide + +This guide is for AI coding agents working in the Svelte monorepo. + +**Important:** Read and follow [`CONTRIBUTING.md`](./CONTRIBUTING.md) as well - it contains essential information about testing, code structure, and contribution guidelines that applies here. + +## Quick Reference + +If asked to do a performance investigation, use the `performance-investigation` skill. diff --git a/benchmarking/compare/profile-diff.mjs b/benchmarking/compare/profile-diff.mjs new file mode 100644 index 0000000000..c6a9061ab2 --- /dev/null +++ b/benchmarking/compare/profile-diff.mjs @@ -0,0 +1,83 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const [benchmark, baseBranch = 'main', candidateBranch] = process.argv.slice(2); + +if (!benchmark || !candidateBranch) { + console.error( + 'Usage: node benchmarking/compare/profile-diff.mjs ' + ); + process.exit(1); +} + +const root = path.resolve('benchmarking/compare/.profiles'); + +function safe(name) { + return name.replace(/[^a-z0-9._-]+/gi, '_'); +} + +function read_profile(branch, bench) { + const file = path.join(root, safe(branch), `${bench}.cpuprofile`); + const profile = JSON.parse(fs.readFileSync(file, 'utf8')); + const nodes = Array.isArray(profile.nodes) ? profile.nodes : []; + const samples = Array.isArray(profile.samples) ? profile.samples : []; + + const id_to_node = new Map(nodes.map((node) => [node.id, node])); + const self_counts = new Map(); + + for (const sample of samples) { + if (typeof sample !== 'number') continue; + self_counts.set(sample, (self_counts.get(sample) ?? 0) + 1); + } + + const total = samples.length || 1; + const by_fn = new Map(); + + for (const [id, count] of self_counts) { + const node = id_to_node.get(id); + if (!node || typeof node !== 'object') continue; + + const frame = node.callFrame ?? {}; + const function_name = frame.functionName || '(anonymous)'; + const url = frame.url || ''; + const line = typeof frame.lineNumber === 'number' ? frame.lineNumber + 1 : 0; + + const label = url + ? `${function_name} @ ${url.replace(/^.*packages\//, 'packages/')}:${line}` + : function_name; + + by_fn.set(label, (by_fn.get(label) ?? 0) + count); + } + + return { by_fn, total }; +} + +const base = read_profile(baseBranch, benchmark); +const candidate = read_profile(candidateBranch, benchmark); + +const keys = new Set([...base.by_fn.keys(), ...candidate.by_fn.keys()]); +const rows = [...keys] + .map((key) => { + const base_pct = ((base.by_fn.get(key) ?? 0) * 100) / base.total; + const candidate_pct = ((candidate.by_fn.get(key) ?? 0) * 100) / candidate.total; + return { + key, + delta: candidate_pct - base_pct, + base_pct, + candidate_pct + }; + }) + .sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta)) + .slice(0, 20); + +console.log(`Benchmark: ${benchmark}`); +console.log(`Base: ${baseBranch}`); +console.log(`Candidate: ${candidateBranch}`); +console.log(''); + +for (const row of rows) { + const sign = row.delta >= 0 ? '+' : ''; + console.log( + `${sign}${row.delta.toFixed(2).padStart(6)}pp candidate ${row.candidate_pct.toFixed(2).padStart(6)}% base ${row.base_pct.toFixed(2).padStart(6)}% ${row.key}` + ); +} diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index ef67f60817..3de6296611 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -42,6 +42,7 @@ import { DEV } from 'esm-env'; import { derived_safe_equal } from '../../reactivity/deriveds.js'; import { current_batch } from '../../reactivity/batch.js'; import * as e from '../../errors.js'; +import { tag } from '../../dev/tracing.js'; // When making substantive changes to this file, validate them with the each block stress test: // https://svelte.dev/playground/1972b2cf46564476ad8c8c6405b23b7b @@ -205,6 +206,10 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f return is_array(collection) ? collection : collection == null ? [] : array_from(collection); }); + if (DEV) { + tag(each_array, '{#each ...}'); + } + /** @type {V[]} */ var array; diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js index 0b89f91a0d..bf9aab2205 100644 --- a/packages/svelte/src/internal/client/reactivity/deriveds.js +++ b/packages/svelte/src/internal/client/reactivity/deriveds.js @@ -244,6 +244,7 @@ export function async_derived(fn, label, location) { return new Promise((fulfil) => { /** @param {Promise} p */ function next(p) { + /** @param {unknown} v */ function go(v) { if (p === promise || v !== STALE_REACTION) { fulfil(signal); diff --git a/packages/svelte/src/internal/client/reactivity/sources.js b/packages/svelte/src/internal/client/reactivity/sources.js index 074f9b4388..1a7cbca886 100644 --- a/packages/svelte/src/internal/client/reactivity/sources.js +++ b/packages/svelte/src/internal/client/reactivity/sources.js @@ -180,14 +180,9 @@ export function set(source, value, should_proxy = false) { */ export function internal_set(source, value, updated_during_traversal = null) { if (!source.equals(value)) { - var batch = Batch.ensure(); - var old_value = source.v; + old_values.set(source, is_destroying_effect ? value : source.v); - if (is_destroying_effect) { - old_values.set(source, value); - } else { - old_values.set(source, old_value); - } + var batch = Batch.ensure(); if ((source.f & DERIVED) !== 0) { const derived = /** @type {Derived} */ (source); diff --git a/packages/svelte/src/reactivity/date.js b/packages/svelte/src/reactivity/date.js index 8e2b5d41ab..f882c05d76 100644 --- a/packages/svelte/src/reactivity/date.js +++ b/packages/svelte/src/reactivity/date.js @@ -95,6 +95,10 @@ export class SvelteDate extends Date { return date_proto[method].apply(this, args); }); + if (DEV) { + tag(d, `SvelteDate.${method}()`); + } + this.#deriveds.set(method, d); set_active_reaction(reaction); diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-resolves-promise/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-resolves-promise/_config.js new file mode 100644 index 0000000000..ff323b60af --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-resolves-promise/_config.js @@ -0,0 +1,22 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + const [x, y, resolve, commit] = target.querySelectorAll('button'); + const [p] = target.querySelectorAll('p'); + + y.click(); + await tick(); + resolve.click(); + await tick(); + x.click(); + await tick(); + assert.htmlEqual(p.innerHTML, '1 0'); + + await tick(); + commit.click(); + assert.htmlEqual(p.innerHTML, '1 1'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-resolves-promise/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-resolves-promise/main.svelte new file mode 100644 index 0000000000..d6995340af --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-resolves-promise/main.svelte @@ -0,0 +1,21 @@ + + +

{x} {await delay(y)}

+ + + + +