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

Proposal: Multi-value Signal using bitmaps (bit arrays) #217

Open
wants to merge 3 commits into
base: main
Choose a base branch
from

Conversation

eddyw
Copy link
Contributor

@eddyw eddyw commented Sep 26, 2022

Hi 👋, this is a PoC of #213 😄 (all tests still passing 😅)

Description

The basic idea of this PR is demonstrate how Signals could be improved to keep track of multiple values (up to 32) using 1 single node to reduce memory footprint and possibly improve performance when multiple (related) values are accessed within a computed or effect.

In this PoC, assume a Signal can have - instead of value - properties signal[0] (alias for signal.value) to signal[31]. Just because numeric properties seem to be accessed faster than string properties (it could have been signal.value0 .. signal.value31 but it's not relevant).

For every get, we keep track of the index - in a bit array - of the accessed property:

get(this: Signal) {
  // ...
  node._version = this._version;
  node._fields |= 1 << i; // i -> 0..31 -> signal[0]..signal[31]
  // ...
  return this._value[i];
}

And every set operation checks whether the node is watching the property whose value is changed:

set(this: Signal, value) {
  // ...
  this._value[i] = value; // i -> 0..31
  // ...
  if (node._fields & (1 << i)) node._target._notify(); // < Notify ONLY if property is watched
}

With this as base, we can create signals that have multiple values (up to 32) and create a lot less nodes if all the accessed properties within a computed belong to the same Signal, for example:

computed(() => signal[0] + signal[1] + signal[2]);
computed(() => signal[1] + signal[5] + signal[31]);

In this particular example, all properties in both computed belong to the same signal, so a single node should be created with bitmap set to fields = (1 << 0) | (1 << 1) | (1 << 2) (for the first). This means that, for example, if value[0] changes, then the second computed won't run even though both use the same signal.

Now, the DX is awkward so this PoC doesn't attempt to be a final version but rather to suggest creating an even lower primitive than Signal. A Signal could be an abstraction for this new primitive where it only uses one field value[0].

An example for such abstraction is the reader function which I included in the code. Which allows creating signals in this manner:

const group = reader({
  foo: "",
  bar: "",
});
// group.foo;
// group.bar;
// group._signals = [Signal]
// group._size = 2;

( Please check the test cases for reader 😄 )

A Signal could look like:

function signal(value) {
  const s = reader({ value });
  s.peek = function() {
    return s._signals[0][0];
  }
}

Of course, this example is just an illustration since better optimizations can be made but I think it conveys the point 😅

But Why?

I want to create a factory which creates classes, sort of like this:

class User extends Factory({
  username: t.String(),
  // ... many other fields
}) {}

Where t.String is a config object which is converted to a property descriptor with getter/setter which would use a signal. However, this consumes a lot of memory and it's (relatively) slow if multiple related properties are accessed within a computed.

I think this could be improved by using a single Signal to watch multiple related properties (well, up to 32). As in the reader PoC example, in one of the tests, you can see that 93 properties can be used with just 3 signals instead of defining 93 signals.

My implementation is just a PoC, at its current state I just hope to spawn some discussion around this if it's interesting enough to move forward within preact/signals (Please check the new test cases).

I think this could also be interesting for #4 . I don't think all this logic should belong to Signal 😅 to be honest, it was just easier to implement it there for a PoC PR.

@changeset-bot
Copy link

changeset-bot bot commented Sep 26, 2022

⚠️ No Changeset found

Latest commit: 7dbc62f

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 26, 2022

Deploy Preview for preact-signals-demo ready!

Name Link
🔨 Latest commit 7dbc62f
🔍 Latest deploy log https://app.netlify.com/sites/preact-signals-demo/deploys/6331e2338ff2970008ff1e67
😎 Deploy Preview https://deploy-preview-217--preact-signals-demo.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify site settings.

@developit
Copy link
Member

Hi Eddy! I have a hunch that this may be a bit out-of-scope for signals, since we're focused on providing a single "box" primitive that folks can build interesting things like this on top of. Perhaps it would make sense to explore this in one of the object/wrapper libraries folks have been working on that use Signals under the hood?

@marvinhagemeister
Copy link
Member

I think this is a fantastic prototype and well worth exploring further! Agree with @developit that it's currently out of scope for signals as we're trying to keep the API as minimal as possible. The PR here would be perfect as a separate package that users can install and try out 👍

@jviide
Copy link
Contributor

jviide commented Oct 6, 2022

In the current tracking scheme (based on versions numbers) each of the 32 different values probably need their own version numbers. That's because a signal can have multiple listeners (effects, computeds) that run their computations in different times. For example in the following example the last computing c2, a.y is 1 message shouldn't get printed to the console:

const a = reader({ x: 0, y: 0 });
const c1 = computed(() => {
	console.log("computing c1, a.x is", a.x);
});
const c2 = computed(() => {
	console.log("computing c2, a.y is", a.y);
});

a.x = 1;  // Set _fields bit 0.
a.y = 1;  // Set _fields bit 1.
c1.value; // Console: computing c1, a.x is 1
c2.value; // Console: computing c2, a.y is 1

a.x = 2;  // _fields bits 0 and 1 stay set.
c1.value; // Console: computing c1, a.x is 2
c2.value; // Console: computing c2, a.y is 1

One workaround that comes to mind would be to set a's bits back to zero e.g. when the corresponding signal value is read by an effect/computed. But a solution like that may break cases where computeds depends on a overlapping subsets of properties, like in this example (that works now, this is just to illustrate a hypothetical 🙂):

const a = reader({ x: 0, y: 0, z: 0 });
const c1 = computed(() => {
	console.log("computing c1, a.x + a.y is", a.x + a.y);
});
const c2 = computed(() => {
	console.log("computing c2, a.y + a.z is", a.y + a.z);
});
c1.value; // should recompute
c2.value; // should recompute

a.y = 2;
c1.value; // should recompute
c2.value; // should recompute

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