Skip to content

fix: incremental batches#18035

Draft
Rich-Harris wants to merge 143 commits into
mainfrom
incremental-batches
Draft

fix: incremental batches#18035
Rich-Harris wants to merge 143 commits into
mainfrom
incremental-batches

Conversation

@Rich-Harris

Copy link
Copy Markdown
Member

This overhauls how batches work. The idea is that instead of reactions having both a write version and a DIRTY (or MAYBE_DIRTY) flag, dirtiness is determined solely by comparing the write versions of a reaction's dependencies with the check version of the reaction itself.

When processing a batch, we 'overlay' the latest values with batch_values, batch_cvs and batch_wvs (which could probably be better-named). This means that we can track whether or not a derived/effect is dirty within the batch, rather than potentially re-running it unnecessarily as a batch is reprocessed following a promise resolution.

If I'm honest, this hasn't really worked out the way I hoped. It makes the logic quite a bit simpler in some cases, but there's actually more code now (though it could probably be massaged down). But worse, it has a very detrimental effect on performance:

results of `pnpm bench:compare`
sbench_create_signals
  time: fastest is b (main)
    a: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 24.57ms
    b: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 24.10ms
  gc_time: fastest is a (incremental-batches)
    a: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 4.40ms
    b: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 4.41ms
sbench_create_0to1
  time: fastest is b (main)
    a: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 124.39ms
    b: ◼                    5.97ms
sbench_create_1to1
  time: fastest is b (main)
    a: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 129.08ms
    b: ◼◼◼                  20.12ms
  gc_time: fastest is b (main)
    a: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 56.46ms
    b: ◼                    2.16ms
sbench_create_2to1
  time: fastest is b (main)
    a: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 84.98ms
    b: ◼◼◼◼                 17.50ms
  gc_time: fastest is b (main)
    a: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 29.00ms
    b: ◼                    2.10ms
sbench_create_4to1
  time: fastest is b (main)
    a: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 32.70ms
    b: ◼◼◼◼◼◼◼◼◼            15.36ms
  gc_time: fastest is b (main)
    a: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 13.85ms
    b: ◼◼◼                  2.03ms
sbench_create_1000to1
  time: fastest is b (main)
    a: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 24.58ms
    b: ◼◼◼◼◼◼◼◼◼◼◼          13.89ms
  gc_time: fastest is b (main)
    a: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 12.17ms
    b: ◼◼◼                  2.02ms
sbench_create_1to2
  time: fastest is b (main)
    a: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 112.20ms
    b: ◼◼                   8.95ms
sbench_create_1to4
  time: fastest is b (main)
    a: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 103.58ms
    b: ◼                    7.47ms
sbench_create_1to8
  time: fastest is b (main)
    a: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 109.34ms
    b: ◼                    6.66ms
sbench_create_1to1000
  time: fastest is b (main)
    a: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 111.41ms
    b: ◼                    7.51ms
  gc_time: fastest is b (main)
    a: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 48.00ms
    b:                      0.27ms
kairo_avoidable_owned
  time: fastest is b (main)
    a: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 1895.72ms
    b: ◼◼◼◼◼◼◼◼◼◼◼◼◼        1231.32ms
  gc_time: fastest is b (main)
    a: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 32.51ms
    b: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼       22.76ms
kairo_avoidable_unowned
  time: fastest is b (main)
    a: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 1899.67ms
    b: ◼◼◼◼◼◼◼◼◼◼◼◼◼        1210.76ms
  gc_time: fastest is b (main)
    a: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 34.27ms
    b: ◼◼◼◼◼◼◼◼◼◼◼◼◼        22.97ms
kairo_broad_owned
  time: fastest is b (main)
    a: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 1838.15ms
    b: ◼◼◼◼◼◼◼◼◼◼◼◼         1113.05ms
  gc_time: fastest is b (main)
    a: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 8.05ms
    b: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼       5.75ms
kairo_broad_unowned
  time: fastest is b (main)
    a: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 1815.56ms
    b: ◼◼◼◼◼◼◼◼◼◼◼◼         1116.09ms
  gc_time: fastest is b (main)
    a: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 7.89ms
    b: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼       5.44ms
kairo_deep_owned
  time: fastest is b (main)
    a: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 1482.06ms
    b: ◼◼◼◼◼◼◼              498.18ms
  gc_time: fastest is b (main)
    a: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 4.72ms
    b: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼      3.44ms
kairo_deep_unowned
  time: fastest is b (main)
    a: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 1475.28ms
    b: ◼◼◼◼◼◼◼              496.21ms
  gc_time: fastest is b (main)
    a: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 5.28ms
    b: ◼◼◼◼◼◼◼◼◼◼◼◼◼        3.44ms
kairo_diamond_owned
  time: fastest is b (main)
    a: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 1651.62ms
    b: ◼◼◼◼◼◼◼◼◼◼◼◼         1012.34ms
  gc_time: fastest is b (main)
    a: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 19.80ms
    b: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼       14.35ms
kairo_diamond_unowned
  time: fastest is b (main)
    a: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 1638.43ms
    b: ◼◼◼◼◼◼◼◼◼◼◼◼         1002.39ms
  gc_time: fastest is b (main)
    a: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 20.13ms
    b: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼      14.66ms
kairo_mux_owned
  time: fastest is b (main)
    a: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 1716.83ms
    b: ◼◼◼◼◼                405.11ms
  gc_time: fastest is b (main)
    a: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 2.68ms
    b: ◼◼◼◼◼◼◼◼◼◼◼          1.47ms
kairo_mux_unowned
  time: fastest is b (main)
    a: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 1710.12ms
    b: ◼◼◼◼◼                393.96ms
  gc_time: fastest is b (main)
    a: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 2.68ms
    b: ◼◼◼◼◼◼◼◼◼◼           1.27ms
kairo_repeated_owned
  time: fastest is b (main)
    a: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 215.94ms
    b: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼       152.27ms
  gc_time: fastest is b (main)
    a: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 2.52ms
    b: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼     2.00ms
kairo_repeated_unowned
  time: fastest is b (main)
    a: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 219.09ms
    b: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼       151.63ms
  gc_time: fastest is b (main)
    a: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 2.76ms
    b: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼       1.93ms
kairo_triangle_owned
  time: fastest is b (main)
    a: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 629.44ms
    b: ◼◼◼◼◼◼◼◼◼◼           304.64ms
  gc_time: fastest is b (main)
    a: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 4.63ms
    b: ◼◼◼◼◼◼◼◼◼◼◼◼         2.83ms
kairo_triangle_unowned
  time: fastest is b (main)
    a: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 630.28ms
    b: ◼◼◼◼◼◼◼◼◼◼           300.77ms
  gc_time: fastest is b (main)
    a: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 4.76ms
    b: ◼◼◼◼◼◼◼◼◼◼◼◼         2.80ms
kairo_unstable_owned
  time: fastest is b (main)
    a: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 299.84ms
    b: ◼◼◼◼◼◼◼◼◼◼◼◼◼        190.36ms
  gc_time: fastest is b (main)
    a: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 2.78ms
    b: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼      2.04ms
kairo_unstable_unowned
  time: fastest is b (main)
    a: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 297.23ms
    b: ◼◼◼◼◼◼◼◼◼◼◼◼         184.86ms
  gc_time: fastest is b (main)
    a: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 2.81ms
    b: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼       1.98ms
mol_owned
  time: fastest is b (main)
    a: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 35.78ms
    b: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼  33.27ms
mol_unowned
  time: fastest is b (main)
    a: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 35.38ms
    b: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼  32.90ms
repeated_deps_owned
  time: fastest is b (main)
    a: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 284.66ms
    b: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼      219.12ms
  gc_time: fastest is b (main)
    a: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 3.83ms
    b: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼   3.37ms
repeated_deps_unowned
  time: fastest is b (main)
    a: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 280.23ms
    b: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼      216.84ms
  gc_time: fastest is b (main)
    a: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 3.98ms
    b: ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼  3.78ms

It's possible that this could be alleviated with a few small tweaks (e.g. writing the overlay directly to the signals, and reverting at the end of batch processing) but as of yet I don't know.

It does help a lot with #17908, which isn't nothing. (In fact that was the original motivation for this work — the fact that we over-execute each block effects is a bad bug, and I wasn't able to fix it any other way; maybe I just need to take another run at it.)

Before submitting the PR, please make sure you do the following

  • It's really useful if your PR references an issue where it is discussed ahead of time. In many cases, features are absent for a reason. For large changes, please create an RFC: https://github.com/sveltejs/rfcs
  • Prefix your PR title with feat:, fix:, chore:, or docs:.
  • This message body should clearly illustrate what problems it solves.
  • Ideally, include a test that fails without this PR but passes with it.
  • If this PR changes code within packages/svelte/src, add a changeset (npx changeset).

Tests and linting

  • Run the tests with pnpm test and lint the project with pnpm lint

Rich-Harris added a commit that referenced this pull request May 29, 2026
tiny self-mergeable change extracted from #18035 — just turns
`each_array` from a `Derived<any[]>` to a `Derived<V[]>`
Rich-Harris added a commit that referenced this pull request May 30, 2026
some more stuff extracted from #18035
@dummdidumm

Copy link
Copy Markdown
Member

This also fixes #18355, though we should add a test for it

@dummdidumm dummdidumm left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gotta say this is surprisingly readable/understandeable, nice! (maybe also because I spent more time with this reactivity system than I'd like to admit).

Has a few rough edges (eager effect) and the perf needs to get better but otherwise this is looking good.

// we treat the earlier values as "already applied". This way we don't need to rerun async
// effects of the earlier batch in case they are merged.
// As a result you can think of batch_values as having the latest values of all intersecting
// batches up until this batch.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why did you remove this comment?

*/
#maybe_dirty_effects = new Set();
/** @type {Map<Reaction, number>} */
cvs = new Map();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what does cvs stand for? This variable need a bit of jsdoc

* 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, [any, boolean]>}
* @type {Map<Value, ValueSnapshot<unknown>>}

@dummdidumm dummdidumm Jun 2, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

comment is outdated, needs to be updated (though see comment further below that we're not using value.f & DERIVED for a reason)

var current = [...batch.current.keys()].filter(
(source) => !(/** @type {[any, boolean]} */ (batch.current.get(source))[1])
);
var current = Array.from(batch.current.keys()).filter((value) => (value.f & DERIVED) === 0);

@dummdidumm dummdidumm Jun 2, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I fear this may be buggy - a derived can be overridden and then acts as state temporarily. This is the reason why it was a [value, is_derived] tuple and not just checking the flag. I see that you removed it everywhere else as well - what was the rationale?

deps: null | Value[];
/** An AbortController that aborts when the signal is destroyed */
ac: null | AbortController;
/** Check version */

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

needs a bit more elaboration what the check version is for

return;
}
if (deps !== null) {
cv = -Infinity;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

elsewhere you do -1, shouldn't that be enough here, too?


if (derived.v === UNINITIALIZED) {
// assigning before first read — execute to track dependencies
execute_derived(derived);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this seems different to before - if a derived should be updated, but someone overrides it before it has a chance to, then the dependencies will be wrong/outdated. So I think we need to keep checking if it's dirty.

active_reaction !== null &&
(active_reaction.f & REACTION_RAN) !== 0
) {
reaction.f |= WAS_MARKED;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i see you had to apply some more work to WAS_MARKED - I really think we should just ditch it in favor of the Set heuristic

var unmount = component_root(() => {
var anchor_node = anchor ?? target.appendChild(create_text());

push({});

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

drive-by or why is it necessary?

// ensure that if any of those untracked writes result in re-invalidation
// of the current effect, then that happens accordingly
if (
!async_mode_flag &&

@dummdidumm dummdidumm Jun 2, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wait, does that mean we chose to not do this anymore in async mode? I didn't even realize we made that change back then.
Edit: Ok I'm pretty sure we did not do that change, this is changing behavior. It's just by accident in another file that this works. Pushing a fix.

…initial run

not marking without not bailing makes two tests fail, revealing that the "don't self-invalidate in async mode" change would actually be a subtle breaking change
@dummdidumm

Copy link
Copy Markdown
Member

Opened #18362 with an idea how to tackle eager effects + async while getting rid of the eager flag.

Replaces the ad-hoc `STATE_EAGER_EFFECT` flag which makes eager effects
over-execute with a mechanism that is two-fold:
1. eager versions are sorted by batch. If a batch belongs to a fork,
that way it's made sure it doesn't "escape" that fork
2. batches now have an `eager` field, which is set for forks with eager
work. It's flushed on commit just before the main commit. This way the
eager work happens separately and eagerly just before the main fork
commits, which itself may not be ready yet.

Additionally in `sources.js` we make sure `active_batch` is set properly
when flushing eager effects.

---------

Co-authored-by: Rich Harris <rich.harris@vercel.com>
Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>
Co-authored-by: Vercel <vercel[bot]@users.noreply.github.com>
Co-authored-by: Rich-Harris <hello@rich-harris.dev>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants