Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement core without Sets, Maps, Arrays etc. #136

Merged
merged 54 commits into from
Sep 16, 2022
Merged

Implement core without Sets, Maps, Arrays etc. #136

merged 54 commits into from
Sep 16, 2022

Conversation

jviide
Copy link
Contributor

@jviide jviide commented Sep 13, 2022

This pull request rewrites the core to avoid allocating Sets, Maps and Arrays on the fly. The implementation uses linked lists of nodes that contain both the source (signal) and target (computed/effect) of dependency-subscription pairs. The aim was to facilitate constant-time subscribe/unsubscribe operations without sets.

Currently this breaks the Preact/React integrations, as the core internals have changed quite a bit.

Very lazily computed signals

Computed signals evaluate their values lazily whenever their .value is accessed or .peek() is called.

Computed signals also lazily subscribe to be notified when their dependencies change only when the computed itself already has subscribers. This is done so that computed signals that aren't currently (direct or indirect) dependencies of some effects or other computed signals can be garbage collected when they go out of scope.

When value of a subscribeless computed signal is accessed the computed signal always checks whether its dependencies have changed. To speed this process up, signals also keep track of their own "version numbers" that change whenever their value changes. Computed signals then store (in the linked list nodes) last version numbers they've seen from their dependencies, and use that info to estimate whether their computation function has to be re-evaluated.

Note that the computed signals could store the actual dependency values instead of version numbers. However I went with the version numbers, because a single dependency value can take an arbitrary amount of memory. As computed signals are lazily evaluated they could theoretically hang on to those already out-of-date dependency values indefinitely before the computed is evaluated again.

When a computed has subscribers it gets notifications about changed dependencies as per usual, and the recomputation process can be short-circuited when the computed signal's ._valid flag is true.

Every signal value change increments a global version number. Computed signals remember what the global version number was when their computed value was last validated. This info is then used to fast-track .value/.peek() calls when nothing has changed globally.

Error handling

If a computed signal value function throws, the error is stored and subsequent .value/.peek() calls throw the same error without re-evaluating the compute function until one of the computed signal's dependency values changes.

If a computed value depends on itself (directly or indirectly) a "cycle detected" error is raised inside the compute function. To prevent runaway recursive effects, the top level batch handler tracks how many operation batches it has had to process consecutively. After 100 iterations all 'signal.value = ...` calls throw a "cycle detected" error.

When an effect in a batch handler throws an error it's percolated to the calling context that started the topmost batch handler. If multiple effects fail inside one batch handling loop then only the first error is percolated to the top.

Notes about avoiding sets

The main use case for using Sets instead the original implementation was to avoid subscribing to the same dependency twice inside an effect or a computed signal's value function. Arrays can be used instead of Sets, but for then avoiding duplicate subscriptions requires checking the array for duplicates, which is an O(n) operation.

This PR tried to solve this with the linked lists, by making signals keep track of the last evaluation context in the stack that has accessed their value. Signals can then can quickly check whether they've already been inserted to the evaluation context's dependencies. This way the check is constant-time.

Signal's ._node property keeps track of the last linked list node in the evaluation stack where the signal is the dependency. This way _node.target value can be used for the evaluation context check described above, but also for recycling nodes.

Those same linked list nodes are also used to remember node.signal's ._node values after entering a new evaluation context, and then for restoring the ._node values when exiting from the evaluation context.

Batching is done with a linked list of effects that should be run in the next batch iteration. To avoid memory churn Effect instances themselves act as nodes in that linked list.

Benchmark

This (extremely simplistic, should be run with d8 etc.) benchmark:

import * as core from "@preact/signals";
import * as setless from "./the-new-core";

{
  const count = core.signal(0);
  const double = core.computed(() => count.value * 2);

  console.time("core");

  for (let i = 0; i < 20000000; i++) {
    count.value++;
    double.value;
  }

  console.timeEnd("core");
}

{
  const count = setless.signal(0);
  const double = setless.computed(() => count.value * 2);

  console.time("setless");

  for (let i = 0; i < 20000000; i++) {
    count.value++;
    double.value;
  }

  console.timeEnd("setless");
}

I get on my machine:

$ npx ts-node benchmark.ts
core: 6.385s
setless: 458.465ms

@changeset-bot
Copy link

changeset-bot bot commented Sep 13, 2022

⚠️ No Changeset found

Latest commit: b5ed584

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@netlify
Copy link

netlify bot commented Sep 13, 2022

Deploy Preview for preact-signals-demo failed.

Name Link
🔨 Latest commit b5ed584
🔍 Latest deploy log https://app.netlify.com/sites/preact-signals-demo/deploys/632433f34301ce0009cfd371

@marvinhagemeister
Copy link
Member

Really like where this is going 🎉

@marvinhagemeister
Copy link
Member

I like even more where this is going 🚀

developit added a commit that referenced this pull request Sep 15, 2022
developit added a commit that referenced this pull request Sep 15, 2022
developit added a commit that referenced this pull request Sep 15, 2022
@jviide jviide changed the base branch from main to setless-core September 16, 2022 08:40
@jviide jviide marked this pull request as ready for review September 16, 2022 08:40
Copy link
Member

@marvinhagemeister marvinhagemeister left a comment

Choose a reason for hiding this comment

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

This is a fantastic piece of engineering!! Really impressed with the results and the speed improvements 👍

@jviide jviide merged commit 4a6288a into preactjs:setless-core Sep 16, 2022
@jviide jviide deleted the setless-core branch September 16, 2022 10:42
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.

4 participants