incremental-batches-perf-attempt
Simon Holthausen 2 days ago
parent aacb5c458d
commit 304c452fa1
No known key found for this signature in database

@ -1,5 +1,5 @@
/** @import { Fork } from 'svelte' */
/** @import { Derived, Effect, Reaction, Source, Value, ValueSnapshot } from '#client' */
/** @import { Derived, Effect, Reaction, Source, Value, ValueRecord, ValueSnapshot } from '#client' */
import {
BLOCK_EFFECT,
BRANCH_EFFECT,
@ -65,6 +65,25 @@ export let current_batch = null;
**/
export let active_batch = null;
/**
* The `values` overlay of the active batch, mirrored into a module-level
* variable (kept in sync via `set_active_batch`) so that hot paths can check
* for the presence of an overlay with a single comparison.
* `null` means we are reading the 'global view'
* @type {Map<Value, ValueSnapshot<unknown>> | null}
*/
export let overlay_values = null;
/**
* Update `active_batch` and `overlay_values`, which must always equal
* `active_batch?.values ?? null`
* @param {Batch | null} batch
*/
function set_active_batch(batch) {
active_batch = batch;
overlay_values = batch === null ? null : batch.values;
}
/**
* This is needed to avoid overwriting inputs
* @type {Batch | null}
@ -127,18 +146,20 @@ export class Batch {
/**
* The current values of any signals that are updated in this batch.
* Tuple format: [value, is_derived] (note: is_derived is false for deriveds, too, if they were overridden via assignment)
* They keys of this map are identical to `this.#previous`
* @type {Map<Value, ValueSnapshot<unknown>>}
* Each record also contains the value/write version from _before_ the update
* took place (`pv`/`pwv`), for time travelling `pv === UNINITIALIZED` means
* there is no previous value
* @type {Map<Value, ValueRecord<unknown>>}
*/
current = new Map();
/**
* The values of any signals (sources and deriveds) that are updated in this batch _before_ those updates took place.
* They keys of this map are identical to `this.#current`
* @type {Map<Value, ValueSnapshot<unknown>>}
* Previous values of signals that are deliberately _not_ (or no longer)
* represented in `current` error values, and deriveds that were
* invalidated during a rebase. Lazily created, as this is rare
* @type {Map<Value, ValueSnapshot<unknown>> | null}
*/
previous = new Map();
#previous = null;
/**
* The combination of this batch's `current` and other batches' `previous` values,
@ -201,6 +222,13 @@ export class Batch {
/** @type {Map<Reaction, number>} */
cvs = new Map();
/**
* `true` if `cvs` contains any deriveds (only the case in rare multi-batch
* scenarios e.g. rebase sentinels, or recomputations under an overlay).
* This lets `get_cv` skip the map lookup for deriveds in the common case
*/
has_derived_cvs = false;
/**
* A map of branches that still exist, but will be destroyed when this batch
* is committed we skip over these during `process`.
@ -433,7 +461,7 @@ export class Batch {
}
}
active_batch = null;
set_active_batch(null);
if (next_batch !== null) {
next_batch.#process();
@ -514,14 +542,33 @@ export class Batch {
* @param {Batch} batch
*/
#merge(batch) {
for (const [source, snapshot] of batch.current) {
var previous = batch.previous.get(source);
for (const [source, record] of batch.current) {
var existing = this.current.get(source);
if (existing === undefined) {
// adopt the record (including its previous value) — unless this batch
// has its own, older previous value for the source
var previous = this.#previous?.get(source);
if (previous !== undefined) {
this.#previous?.delete(source);
record.pv = previous.v;
record.pwv = previous.wv;
record.p = previous;
}
if (previous && !this.previous.has(source)) {
this.previous.set(source, previous);
this.current.set(source, record);
} else {
existing.v = record.v;
existing.wv = record.wv;
// this batch's own previous value takes precedence, if it has one
if (existing.pv === UNINITIALIZED && record.pv !== UNINITIALIZED) {
existing.pv = record.pv;
existing.pwv = record.pwv;
existing.p = record.p;
}
}
this.current.set(source, snapshot);
}
for (const [effect, deferred] of batch.async_deriveds) {
@ -612,18 +659,37 @@ export class Batch {
* @param {number} wv
*/
capture(value, v, wv) {
if (value.v !== UNINITIALIZED && !this.previous.has(value)) {
this.previous.set(value, { v: value.v, wv: value.wv });
}
var record = this.current.get(value);
// Don't save errors or they won't be thrown in `runtime.js#get`
if ((value.f & ERROR_VALUE) === 0) {
var snapshot = { v, wv };
if ((value.f & ERROR_VALUE) !== 0) {
// Don't save errors, or they won't be thrown in `runtime.js#get` —
// but do preserve the previous value, for time travelling
if (record === undefined && value.v !== UNINITIALIZED) {
var stash = (this.#previous ??= new Map());
this.current.delete(value); // order must be preserved
if (!stash.has(value)) {
stash.set(value, { v: value.v, wv: value.wv });
}
}
} else if (record === undefined) {
// note: we don't need to preserve write order in the map — every
// consumer of `current` uses the write versions in the records,
// which encode the ordering
var previous = this.#previous?.get(value);
if (previous !== undefined) {
this.#previous?.delete(value);
record = { v, wv, pv: previous.v, pwv: previous.wv, p: previous };
} else {
record = { v, wv, pv: value.v, pwv: value.wv, p: null };
}
this.current.set(value, snapshot);
active_batch?.values?.set(value, snapshot);
this.current.set(value, record);
overlay_values?.set(value, record);
} else {
record.v = v;
record.wv = wv;
overlay_values?.set(value, record);
}
if (!this.is_fork) {
@ -632,13 +698,30 @@ export class Batch {
}
}
/**
* Remove a value from `current`, so that it is no longer treated as having
* been changed by this batch (e.g. a derived that needs to be recomputed
* following a rebase). Its previous value is preserved for time travelling
* @param {Value} value
*/
uncapture(value) {
var record = this.current.get(value);
if (record === undefined) return;
this.current.delete(value);
if (record.pv !== UNINITIALIZED) {
(this.#previous ??= new Map()).set(value, (record.p ??= { v: record.pv, wv: record.pwv }));
}
}
activate() {
current_batch = this;
}
deactivate() {
current_batch = null;
active_batch = null;
set_active_batch(null);
}
flush() {
@ -657,7 +740,7 @@ export class Batch {
is_processing = false;
current_batch = null;
active_batch = null;
set_active_batch(null);
old_values.clear();
@ -710,8 +793,12 @@ export class Batch {
if (batch_snapshot) {
if (is_earlier && snapshot.v !== batch_snapshot.v) {
// bring the value up to date
batch.current.set(source, snapshot);
// bring the value up to date. Note that we mutate the existing
// record rather than sharing ours — records are mutable, and
// sharing one between multiple live batches would cause
// writes in one batch to leak into another
batch_snapshot.v = snapshot.v;
batch_snapshot.wv = snapshot.wv;
} else {
// same value or later batch has more recent value,
// no need to re-run these effects
@ -896,16 +983,24 @@ export class Batch {
apply() {
if (!async_mode_flag) {
// TODO previously we bailed here if there was only one (non-fork) batch... maybe we can reinstate that
return;
}
if (active_batch === this) return;
active_batch = this;
// If this is the only batch, and not a fork, the 'global view' is already
// correct (values are written through to the signals as they change) —
// we don't need an overlay, and `is_dirty` can rely on the `CLEAN` flag
if (!this.is_fork && this.#prev === null && this.#next === null) {
this.values = null;
set_active_batch(this);
return;
}
if (active_batch === this && this.values !== null) return;
// if there are multiple batches, we are 'time travelling' —
// we need to override values with the ones in this batch...
this.values = new Map(this.current);
set_active_batch(this);
// ...and undo changes belonging to other batches unless they intersect
for (let batch = first_batch; batch !== null; batch = batch.#next) {
@ -925,9 +1020,17 @@ export class Batch {
}
if (!intersects) {
for (const [value, snapshot] of batch.previous) {
if (!this.values.has(value)) {
this.values.set(value, snapshot);
for (const [value, record] of batch.current) {
if (record.pv !== UNINITIALIZED && !this.values.has(value)) {
this.values.set(value, (record.p ??= { v: record.pv, wv: record.pwv }));
}
}
if (batch.#previous !== null) {
for (const [value, snapshot] of batch.#previous) {
if (!this.values.has(value)) {
this.values.set(value, snapshot);
}
}
}
}
@ -941,6 +1044,10 @@ export class Batch {
schedule(effect) {
last_scheduled_effect = effect;
// the effect can no longer be considered definitely-clean,
// its dependencies need to be checked when it is reached
effect.f &= ~CLEAN;
if (!this.cvs.has(effect)) {
this.cvs.set(effect, effect.cv);
}
@ -1188,8 +1295,13 @@ function mark_effects(batch, value, sources, marked, checked) {
const flags = reaction.f;
if ((flags & DERIVED) !== 0) {
batch.current.delete(/** @type {Derived} */ (reaction));
batch.uncapture(/** @type {Derived} */ (reaction));
batch.cvs.set(/** @type {Derived} */ (reaction), -1);
batch.has_derived_cvs = true;
// the sentinel above forces a recomputation within `batch` —
// it must not be bypassed by a cached 'definitely clean' state
reaction.f &= ~CLEAN;
mark_effects(batch, /** @type {Derived} */ (reaction), sources, marked, checked);
} else if ((flags & (ASYNC | BLOCK_EFFECT)) !== 0 && depends_on(reaction, sources, checked)) {
@ -1310,10 +1422,10 @@ export function eager(fn) {
try {
running_eager_effect = true;
active_batch = null;
set_active_batch(null);
value = fn();
} finally {
active_batch = previous_batch;
set_active_batch(previous_batch);
running_eager_effect = previous_running_eager_effect;
}
@ -1480,7 +1592,7 @@ export function fork(fn) {
* @param {Value} value
*/
export function get_wv(value) {
var snapshot = active_batch?.values?.get(value);
var snapshot = overlay_values?.get(value);
return snapshot ? snapshot.wv : value.wv;
}
@ -1488,16 +1600,52 @@ export function get_wv(value) {
* @param {Reaction} reaction
*/
export function get_cv(reaction) {
return active_batch?.cvs.get(reaction) ?? reaction.cv;
if (active_batch !== null && ((reaction.f & DERIVED) === 0 || active_batch.has_derived_cvs)) {
var cv = active_batch.cvs.get(reaction);
if (cv !== undefined) return cv;
}
return reaction.cv;
}
/**
* @param {Reaction} reaction
*/
export function set_cv(reaction, cv = write_version) {
// TODO seems weird to have both of these
if ((reaction.f & DERIVED) !== 0) {
// For deriveds, only update the global check version if it was computed
// against the global view. A check version computed under a divergent
// overlay could claim more freshness than the (written-through, global)
// values warrant, causing reads in the global view to skip a necessary
// recomputation
if (!current_batch?.is_fork && !running_eager_effect && overlay_values === null) {
// in the global view, the batch-scoped entries are redundant —
// `get_cv` falls back to the global check version we write here
reaction.cv = cv;
return;
}
if (current_batch !== null) {
current_batch.cvs.set(reaction, cv);
current_batch.has_derived_cvs = true;
}
if (active_batch !== null && active_batch !== current_batch) {
active_batch.cvs.set(reaction, cv);
active_batch.has_derived_cvs = true;
}
return;
}
// For effects the batch-scoped entries are always needed (they update the
// snapshots taken in `schedule`, without which an effect that ran would
// still appear dirty). The global check version is also always updated:
// re-runs with other batches' values are handled explicitly during
// rebasing, and a stale-low check version would cause spurious re-runs
// (cancelling in-flight async work)
current_batch?.cvs.set(reaction, cv);
active_batch?.cvs.set(reaction, cv);
if (active_batch !== current_batch) active_batch?.cvs.set(reaction, cv);
if (!current_batch?.is_fork && !running_eager_effect) {
reaction.cv = cv;

@ -36,7 +36,7 @@ import { get_error } from '../../shared/dev.js';
import { async_mode_flag, tracing_mode_flag } from '../../flags/index.js';
import { component_context } from '../context.js';
import { UNINITIALIZED } from '../../../constants.js';
import { current_batch, get_wv, active_batch, set_cv, previous_batch } from './batch.js';
import { current_batch, active_batch, overlay_values, set_cv, previous_batch } from './batch.js';
import { increment_pending, unset_context } from './async.js';
import { deferred, noop } from '../../shared/utils.js';
@ -345,9 +345,12 @@ export function execute_derived(derived) {
set_active_effect(parent);
derived_stack ??= [];
if (DEV) {
// the stack allows us to detect self-referencing deriveds (in `get`).
// it is maintained in DEV only, because scanning it on every read
// is too expensive for production
derived_stack ??= [];
// TODO don't we need eager effects in prod too?
let prev_eager_effects = eager_effects;
set_eager_effects(new Set());
@ -364,13 +367,11 @@ export function execute_derived(derived) {
}
} else {
try {
derived_stack.push(derived);
derived.f &= ~WAS_MARKED;
destroy_derived_effects(derived);
value = update_reaction(derived);
} finally {
set_active_effect(prev_active_effect);
derived_stack.pop();
}
}
@ -386,12 +387,20 @@ export function update_derived(derived) {
var deps = derived.deps;
var cv = Infinity;
var overlay = overlay_values;
if (deps !== null) {
cv = -Infinity;
for (var i = 0; i < deps.length; i++) {
var dep_wv = get_wv(deps[i]);
var dep = deps[i];
var dep_wv = dep.wv;
if (overlay !== null) {
var snapshot = overlay.get(dep);
if (snapshot !== undefined) dep_wv = snapshot.wv;
}
if (dep_wv > cv) cv = dep_wv;
}
}
@ -415,7 +424,11 @@ export function update_derived(derived) {
}
}
if (active_batch === null && (derived.f & CONNECTED) !== 0) {
// In the global view (no overlay active) we can cache the fact that the derived
// is now consistent with its dependencies, so that reads can skip the dependency
// check in `is_dirty`. Don't do this inside a cleanup function, or we would
// cache a stale value
if (!is_destroying_effect && (derived.f & CONNECTED) !== 0 && overlay_values === null) {
derived.f |= CLEAN;
}
}

@ -1,9 +1,9 @@
/** @import { Equals } from '#client' */
import { active_batch } from './batch.js';
import { overlay_values } from './batch.js';
/** @type {Equals} */
export function equals(value) {
var snapshot = active_batch?.values?.get(this);
var snapshot = overlay_values?.get(this);
return value === (snapshot ? snapshot.v : this.v);
}

@ -30,7 +30,7 @@ import {
REACTION_RAN
} from '#client/constants';
import * as e from '../errors.js';
import { legacy_mode_flag, tracing_mode_flag } from '../../flags/index.js';
import { async_mode_flag, legacy_mode_flag, tracing_mode_flag } from '../../flags/index.js';
import { tag_proxy } from '../dev/tracing.js';
import { get_error } from '../../shared/dev.js';
import { component_context, is_runes } from '../context.js';
@ -40,7 +40,7 @@ import {
legacy_updates,
set_cv,
get_cv,
active_batch,
overlay_values,
current_batch
} from './batch.js';
import { proxy } from '../proxy.js';
@ -193,6 +193,14 @@ export function internal_set(source, value, updated_during_traversal = null) {
}
set_cv(derived);
// an assignment behaves like a source write: the value is written
// through to the signal (below, in `capture`), so the global check
// version must be written too — the pair is consistent regardless
// of any active overlay
if (!batch.is_fork) {
derived.cv = write_version;
}
}
batch.capture(source, value, increment_write_version());
@ -341,6 +349,8 @@ export function mark_reactions(batch, signal, wv, updated_during_traversal) {
// if (wv <= get_cv(reaction)) continue;
if ((flags & EAGER_EFFECT) !== 0) {
reaction.f &= ~CLEAN;
// Eager effects need to run immediately:
// - for $inspect so that the stack trace makes sense
// - for $state.eager because they might be without an effect parent
@ -352,9 +362,16 @@ export function mark_reactions(batch, signal, wv, updated_during_traversal) {
// If setting state inside an effect, `batch !== active_batch` —
// we need to invalidate the current overlay so that subsequent
// effects read the correct value
active_batch?.values?.delete(derived);
overlay_values?.delete(derived);
// In sync mode `update_derived` never captures deriveds into
// `batch.current` (only assignments do), and none of the machinery
// that consumes stale derived entries (overlays, commit rebasing)
// runs — so we can skip this in that case
if (async_mode_flag) {
batch.uncapture(derived);
}
batch.current.delete(derived);
derived.f &= ~CLEAN;
if ((flags & WAS_MARKED) === 0) {
@ -373,6 +390,8 @@ export function mark_reactions(batch, signal, wv, updated_during_traversal) {
} else {
var effect = /** @type {Effect} */ (reaction);
effect.f &= ~CLEAN;
if ((flags & BLOCK_EFFECT) !== 0 && eager_block_effects !== null) {
eager_block_effects.add(effect);
}

@ -109,3 +109,12 @@ export interface ValueSnapshot<T = unknown> {
v: T;
wv: number;
}
export interface ValueRecord<T = unknown> extends ValueSnapshot<T> {
/** the value before the batch's first write (`UNINITIALIZED` if there was none) */
pv: unknown;
/** the write version before the batch's first write */
pwv: number;
/** lazily created `{ v: pv, wv: pwv }` snapshot, for time travelling */
p: ValueSnapshot<T> | null;
}

@ -51,9 +51,8 @@ import {
current_batch,
flushSync,
get_cv,
active_batch,
overlay_values,
set_cv,
get_wv,
previous_batch
} from './reactivity/batch.js';
import { handle_error } from './error-handling.js';
@ -162,20 +161,28 @@ export function is_dirty(reaction) {
return true;
}
// If no overlay is active (`overlay_values === null`), we are reading the
// 'global view'. In the global view the `CLEAN` flag is a cache of the
// dependency check below: it guarantees that no dependency has a write
// version newer than the reaction's check version. It must be ignored while
// an overlay is active, because the overlay may present different
// values/versions than the global ones the flag was computed against.
var overlay = overlay_values;
if (flags & DERIVED) {
reaction.f &= ~WAS_MARKED;
if ((flags & CONNECTED) !== 0) {
if (active_batch !== null) {
if (active_batch.values?.has(/** @type {Derived} */ (reaction))) {
return false;
}
} else {
if ((reaction.f & CLEAN) !== 0) {
if (overlay !== null) {
if (overlay.has(/** @type {Derived} */ (reaction))) {
return false;
}
} else if ((flags & CLEAN) !== 0) {
return false;
}
}
} else if (overlay === null && (flags & CLEAN) !== 0) {
return false;
}
var dependencies = /** @type {Value[]} */ (reaction.deps);
@ -197,11 +204,24 @@ export function is_dirty(reaction) {
}
}
if (get_wv(dependency) > cv) {
var wv = dependency.wv;
if (overlay !== null) {
var snapshot = overlay.get(dependency);
if (snapshot !== undefined) wv = snapshot.wv;
}
if (wv > cv) {
return true;
}
}
// cache the result of the dependency check, so that subsequent
// reads in the global view can skip it
if (overlay === null && (flags & CONNECTED) !== 0) {
reaction.f |= CLEAN;
}
return false;
}
@ -402,10 +422,12 @@ function remove_reaction(signal, dependency) {
var derived = /** @type {Derived} */ (dependency);
// If we are working with a derived that is owned by an effect, then mark it as being
// disconnected and remove the mark flag, as it cannot be reliably removed otherwise
// disconnected and remove the mark flag, as it cannot be reliably removed otherwise.
// A disconnected derived no longer receives invalidations, so it must not
// carry a `CLEAN` flag that would exempt it from checking its dependencies
if ((derived.f & CONNECTED) !== 0) {
derived.f ^= CONNECTED;
derived.f &= ~WAS_MARKED;
derived.f &= ~(WAS_MARKED | CLEAN);
}
// freeze any effects inside this derived
@ -441,6 +463,11 @@ export function update_effect(effect) {
return;
}
// mark the effect clean before running it — writes to its dependencies
// during execution will unset the flag again (via `mark_reactions`
// or `schedule`), causing a re-run
effect.f |= CLEAN;
var previous_effect = active_effect;
var was_updating_effect = is_updating_effect;
@ -645,7 +672,9 @@ export function get(signal) {
if (is_derived) {
var derived = /** @type {Derived} */ (signal);
if (derived_stack !== null && includes.call(derived_stack, derived)) {
// `derived_stack` is only maintained in DEV - would be too costly in production,
// so a self-referencing derived returns its stale value there
if (DEV && derived_stack !== null && includes.call(derived_stack, derived)) {
e.derived_references_self();
}
@ -689,8 +718,10 @@ export function get(signal) {
}
}
var snapshot = active_batch?.values?.get(signal);
if (snapshot) return /** @type {V} */ (snapshot.v);
if (overlay_values !== null) {
var snapshot = overlay_values.get(signal);
if (snapshot !== undefined) return /** @type {V} */ (snapshot.v);
}
if ((signal.f & ERROR_VALUE) !== 0) {
throw signal.v;

@ -0,0 +1,74 @@
import { tick } from 'svelte';
import { test } from '../../test';
// Guards against 'definitely clean' state (the `CLEAN` flag / a too-new check
// version) leaking between batches: `sum` is affected by both the deferred
// batch (which changes `a`) and the quicker batch (which changes `b`).
// Consuming the invalidation in one batch/view must not prevent other views
// from recomputing `sum` with their own values
export default test({
async test({ assert, target, logs }) {
await tick();
const [a, b, shift, read] = target.querySelectorAll('button');
assert.htmlEqual(
target.innerHTML,
`
<button>a</button>
<button>b</button>
<button>shift</button>
<button>read</button>
<p>10</p>
<p>0</p>
`
);
// batch A: changes `a`, is deferred while `delay(a)` is pending
a.click();
await tick();
// batch B: changes `b`. It doesn't intersect with A, so it commits
// independently with its own consistent view (a = 0, b = 20) —
// `a`'s change is withheld until the async work is done
b.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>a</button>
<button>b</button>
<button>shift</button>
<button>read</button>
<p>20</p>
<p>0</p>
`
);
// reading `sum` outside any batch sees the written-through values
// of both batches — this read must not poison the state that batch A
// relies on to recompute `sum` when it resumes
read.click();
assert.deepEqual(logs, [21]);
// resolve batch A — `sum` must reflect both changes
shift.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>a</button>
<button>b</button>
<button>shift</button>
<button>read</button>
<p>21</p>
<p>1</p>
`
);
read.click();
assert.deepEqual(logs, [21, 21]);
}
});

@ -0,0 +1,21 @@
<script>
let a = $state(0);
let b = $state(10);
const deferred = [];
function delay(value) {
if (!value) return value;
return new Promise((resolve) => deferred.push(() => resolve(value)));
}
let sum = $derived(a + b);
</script>
<button onclick={() => (a += 1)}>a</button>
<button onclick={() => (b += 10)}>b</button>
<button onclick={() => deferred.shift()?.()}>shift</button>
<button onclick={() => console.log(sum)}>read</button>
<p>{sum}</p>
<p>{await delay(a)}</p>
Loading…
Cancel
Save