You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
* perf(promise,object): O(1) promise settle tables + per-object write gate (#6084)
Closes the two remaining measured defects on #6084.
Item 2 — promise settle was O(N^2). js_promise_resolve/reject drain three
promise-pointer-keyed side tables (PROMISE_SETTLE_LISTENERS,
PROMISE_OVERFLOW_REACTIONS, PROMISE_ALL_STATES); all three were
Vec<(usize, T)> scanned END TO END on every settle, so settling N parked
promises is quadratic. The per-sweep death hook had the same shape.
Not a data-structure swap: these are GC root side tables whose Vec is
load-bearing twice — the incremental root scanner resumes by position
(PromiseRootScanState { index, slot }), and evacuation rewrites the key IN
PLACE (visit_metadata_usize_slot), which would silently break a HashMap's
bucket invariant.
New promise/keyed_table.rs: PromiseKeyedTable<T>, the 1:N analogue of the
PromiseContextStore split (#6267). The dense Vec stays exactly as the GC
traversal/rewrite surface (same access pattern, same append + swap-remove
discipline); an O(1) key -> positions index is layered on top as derived
state. Every GC path that can invalidate it just sets index_dirty — nothing
patches the index from inside a GC path — and the next lookup rebuilds it
from the entries alone, at most once per cycle that actually moved a
promise. A monotonic per-entry seq restores the observable per-key FIFO
order that swap_remove destroys, and is what makes the index reconstructible
from entries alone.
Measured (release, min of 7), N pending promises each with a 2nd .then:
40k settle 1117ms -> 14ms (80x; node 1ms). 4x the promises costs main 16.0x
the time (quadratic) and this branch 7x. The Promise.all shape (one reaction
per input, inline slot) is unchanged: 8ms vs 8ms.
Item 6 — the dynamic-write fast path was disabled process-wide. Both write
fast paths gated on the process-global GLOBAL_DESCRIPTORS_IN_USE latch, which
flips on ANY descriptor install anywhere and never reverts. The issue reports
"+29% after an unrelated Object.freeze"; in fact the latch is already set
before user code runs (the runtime installs latching descriptors during
startup/first use: an Error's stack accessor, arguments objects,
tagged-template raw, typed-array props), so these fast paths were effectively
dead in every program.
plain_data_write_may_intercept() replaces the latch on both gates with the
same per-receiver predicate ordinary_set's #5054 fast path already applies:
own descriptors are visible in OBJ_FLAG_HAS_DESCRIPTORS (folded into the
existing FROZEN|SEALED|NO_EXTEND mask, so free); only prototype-level installs
can intercept a write to an object whose own flag is clear, so those are
checked against the actual chain (Object.prototype per key, a recorded
setPrototypeOf target, or the class chain); typed arrays and exotic-expando
hosts are excluded outright. When no descriptor exists anywhere it
short-circuits on the same single relaxed load the old gate did.
Measured: 1M objects x 3 new props, with no descriptor call anywhere in the
program, 2845ms -> 1465ms (1.9x). After an unrelated Object.freeze,
3026ms -> 1557ms.
Correctness: item 6 re-enables a path that was dead on main, so its semantics
are pinned rather than assumed. Every interception source still intercepts,
byte-identical to node — inherited setter and non-writable on
Object.prototype, own accessor and own non-writable, frozen/sealed receivers,
setPrototypeOf and Object.create protos with a setter, class accessors, and
defineProperty on a class prototype — while plain data writes on the same
objects keep the fast path. Reaction FIFO order across the inline slot plus
overflow entries is preserved. The GC rekey path is covered deterministically
by gc/tests/copying/promise_side_tables.rs
(test_live_promise_side_table_entries_rekeyed_by_copied_minor moves the
promise and asserts the entries are findable under the new address).
cargo test -p perry-runtime: 1271 passed, 0 failed. Gap suite: no new
failures.
typed_feedback_object_set_fast_hits_learned_dynamic_key_transition asserted
the old semantics ("global latch set => fast path must bail"), which is
exactly what item 6 removes; it now asserts the per-receiver behaviour.
* perf(promise): make displaced-entry relocation O(1) (#6084 review)
CodeRabbit on #6327: the O(1) key -> positions index left a residual
quadratic in the *relocation* path, and the benchmark that shipped with the
PR (many keys, ONE entry each -- the Promise.all shape) cannot see it.
take_all(A) swap_removes A's entries; each removal displaces the table's
last entry -- always a foreign one -- into the vacated slot, and that
entry's key's slot list has to be repointed at its new home. Slots::relocate
found the old position by SCANNING the list, which is O(M) for a key with M
parked entries. So `for(M) a.then(f); for(M) b.then(f); resolveA()` -- all of
A's entries ahead of all of B's -- drains A in Theta(M^2): the exact defect
this table exists to remove, just relocated from the table into a slot list.
`p.then()` past the inline slot parks in PROMISE_OVERFLOW_REACTIONS, so this
is reachable from plain TS.
Measured (release), M entries on key A and M on key B, time to drain A:
M=20k 41.3ms -> 0.2ms
M=80k 638.6ms -> 0.7ms
Before: 4x the entries cost 15.5x the time (quadratic is 16x). After: 3.5x.
Fix: each Entry records `slot`, its own offset within its key's slot list --
the reverse of the forward index. A displaced entry names the slot to
repoint, so relocation is one indexed store.
`slot` is derived state on exactly the same footing as `index` itself:
maintained by push/ensure_index only, meaningless while index_dirty, rebuilt
wholesale by ensure_index, and never touched from a GC path. The dense Vec
remains the GC traversal/rewrite surface and the monotonic seq still carries
FIFO order.
Adds the two-heavily-populated-keys regression test (it fails on the old
scan with the 4x-entries/16x-time signature), and assert_invariants now
checks the reverse index -- which the randomized differential test, now
asserting as it goes, exercises against the naive model.
---------
Co-authored-by: Ralph Küpper <ralph@skelpo.com>
0 commit comments