Skip to content

Optimize#50

Merged
fynyky merged 4 commits into
masterfrom
optimize
May 26, 2026
Merged

Optimize#50
fynyky merged 4 commits into
masterfrom
optimize

Conversation

@fynyky
Copy link
Copy Markdown
Owner

@fynyky fynyky commented May 25, 2026

No description provided.

fynyky and others added 4 commits May 25, 2026 18:14
Three distinct problems caused Reactor arrays to be slow:

1. sort() was O(n² log n) instead of O(n log n)
   Array.prototype.sort() performs O(n log n) element swaps. Each swap
   goes through the defineProperty trap, which called trigger(), which
   unconditionally called Reflect.ownKeys() — an O(n) scan — then built
   two full Sets to compare them. The result was O(n) work per swap:
   O(n log n) swaps × O(n) per swap = O(n² log n) overall.
   For n=5000 this was ~6800ms; after the fix it is ~80ms.

2. forEach/map had high constant-factor overhead outside observers
   Every element read through the get trap created or looked up a Signal,
   called signalCore.read(), and checked the dependency stack, even when
   no observer was tracking the read. For large arrays this added up to
   a ~10–15× overhead over a direct array access.

3. ownKeys check was coupled into the public batch() API
   The initial fix for (1) deferred the ownKeys scan to the end of each
   batch() call, which required batch() to know about pendingOwnKeyChecks
   — an internal reactor concern that had no place in a public utility.

--- How the new system works ---

checkReactorOwnKeys(reactorCore)  [new top-level helper]
  Computes the current key set of a reactor's source, compares it against
  the previously stored set using a Set-difference check, and writes to
  selfSignal only if the set actually changed. Used in two places:
  the apply trap's flush loop and trigger()'s synchronous fallback.

pendingOwnKeyChecks  [global, null when no apply trap is active]
  A Set of reactor cores that need their ownKeys checked before observers
  fire. Non-null only while an apply trap's batch execute is running.
  null is the signal to trigger() that no apply trap is in the call stack.

apply trap  [owns the pendingOwnKeyChecks lifecycle]
  On entry to its batch execute it saves the current pendingOwnKeyChecks
  (handles nested apply traps, e.g. a map callback that calls sort) and
  installs a fresh empty Set. The native method runs; every trigger() call
  during that method finds a non-null Set and defers into it rather than
  computing ownKeys immediately. A try/finally then iterates the Set and
  calls checkReactorOwnKeys() once per unique reactor — still inside
  batch's execute phase, so before the observer loop runs. The saved Set
  is restored on exit, keeping the lifecycles properly nested.

get trap  [fast path when no observer is watching]
  If dependencyStack.length === 0 no observer is tracking reads, so the
  entire signal machinery (lazy Signal creation, signalCore lookup, read
  registration) can be skipped. The raw value is resolved and returned
  directly (wrapped in a Reactor if it is an object). The slow path with
  full signal tracking is taken only when an observer is present.

trigger()  [defers or checks synchronously]
  After updating the per-property getSignal and hasSignal, it checks
  pendingOwnKeyChecks. If non-null (apply trap is active), it adds the
  reactor core to the set and returns — O(1), deduplicated automatically
  by Set semantics. If null (direct property write, or a user-level
  batch() call with no apply trap), it calls checkReactorOwnKeys()
  immediately so that ownKeys-watching observers are still notified.

batch()  [unchanged from its original form]
  The public API knows nothing about ownKeys. It only manages the batcher
  Set and the observer trigger loop. All ownKeys logic lives in the apply
  trap and trigger().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1. Signal.write() — eliminate throwaway Array allocation
   Array.from(this.dependents).forEach(...) was called on every signal
   write, allocating a temporary Array purely to iterate it once. The
   only reason for the snapshot was to allow safe modification of the
   batcher Set while iterating — but batcher is a *different* Set from
   this.dependents, so the snapshot was never necessary. Replaced with
   a plain for...of loop directly over the Set.

2. Object.create(null) for getSignals and hasSignals
   Both maps were plain {} objects, so accessing a key like "constructor"
   or "toString" would traverse the prototype chain and find Object
   prototype properties instead of returning undefined. The workaround
   was Object.prototype.hasOwnProperty.call(map, key) on every read —
   a long call chain for every property access. Switching both to
   Object.create(null) gives prototype-free maps, so a simple truthiness
   check (!map[key]) is sufficient and safe.

3. trigger() — lazy Reflect.get / Reflect.has
   trigger() previously pre-computed getValue and hasValue unconditionally,
   paying for two Reflect calls on every property write even when neither
   getSignals[property] nor hasSignals[property] existed (i.e. nobody was
   watching that property). Moved both inside their respective if-guards so
   they are only evaluated when the corresponding signal is actually present.
   For sort() on an unwatched array this saves O(n log n) Reflect calls.

4. has trap — fast path when no observer is active
   The `in` operator went through full signal machinery (lazy Signal
   creation, signalCore lookups, read registration) even outside any
   observer context. Added dependencyStack.length === 0 early return that
   delegates straight to Reflect.has, matching the pattern already used by
   the get trap. Measured improvement: 31ms → 7ms for 100k checks.

5. ownKeys trap — fast path when no observer is active
   Object.keys() / for…in / Reflect.ownKeys() on a Reactor was
   going through selfSignal machinery on every call regardless of whether
   any observer was watching, making it 1731ms for 100k calls. Added the
   same dependencyStack.length === 0 fast path. Measured improvement:
   1731ms → 204ms (the residual cost is the Proxy runtime's internal
   getOwnPropertyDescriptor calls for enumerability filtering, which are
   unavoidable without adding a getOwnPropertyDescriptor trap).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- apply handler: nested try structure (HEAD) + flat() fix (master)
- get handler: keep HEAD's fast-path optimization, drop master's pre-optimization signal instantiation
- hasSignals: keep HEAD's Object.create(null) null-prototype approach
- has handler: keep HEAD's plain property access (safe with null-prototype)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The outer try-finally and inner try-catch had identical behavior to a
single try-catch-finally, since finally runs unconditionally regardless
of whether the catch returns or re-throws.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@fynyky fynyky merged commit d2b428e into master May 26, 2026
3 checks passed
@fynyky fynyky deleted the optimize branch May 26, 2026 02:50
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.

1 participant