Conversation
|
|
||
| if (Array.isArray(iterable)) { | ||
| return new ArrayIterator(iterable, keyFor); | ||
| // When iterating a TrackedArray (Proxy), each index access goes through |
There was a problem hiding this comment.
well this is dubious lol
| #collection = createUpdatableTag(); | ||
|
|
||
| #storages = new Map<number, ReturnType<typeof createUpdatableTag>>(); | ||
| // Use a flat array instead of Map for index-based storage lookups. |
Correctness fixes: - validators.ts: Restore try/finally for isUpdating recovery, use > with NaN fallback for volatile tag propagation - reference.ts: Use track() for error-safe tracking frames (try/catch in valueForRef kills V8 JIT optimization) - iterable.ts: Don't reuse iterator result objects (callers store refs) - tracking.ts: Avoid corrupting combinator tags when pooling trackers Performance optimizations: - reference.ts: Fast path for iterator item refs — skip tracking frame and closures entirely since compute only consumes one tag. Eliminates ~40,000 allocations per 5000-item list render. - destroyable: Swap-and-pop removal instead of indexOf+splice, reducing array shifting from O(n) to O(1) per removal. - element-builder.ts: Eliminate First/Last wrapper class allocations, store raw SimpleNode references with boolean discriminator. - tracking.ts: Hand off tags array to combine() instead of slice() copy. Remove unwrap() from hot endTrackFrame path. - update.ts: Remove bulk-clear code that was slower than single-pass (3 passes over data vs 1). - stack.ts: Fast path for capture(0). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Bug fixes: - validators.ts: Move isUpdating check BEFORE lastChecked early return. During cycle detection, lastChecked is already set to $REVISION, so the early return was firing before the cycle could be detected. - iterable.ts: Handle empty arrays in ArrayIterator.next() — return null instead of producing a spurious item with undefined value. - reference.ts: Use track() for error-safe tracking (try/catch in valueForRef or separate function both kill V8 JIT optimization). Performance optimizations: - reference.ts: Fast path for iterator item refs — skip tracking frame and closures entirely (~40,000 fewer allocations per 5000-item render). - destroyable: Swap-and-pop removal instead of indexOf+splice. - element-builder.ts: Eliminate First/Last wrapper allocations. - tracking.ts: Hand off tags array instead of slice(); remove unwrap() from hot endTrackFrame path. - update.ts: Remove slower bulk-clear code. - stack.ts: Fast path for capture(0). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Skips the unused return-value read from pop(). Semantically identical (both truncate the array by one; the packed-array fast path is preserved in either form). Also byte-identical to the same change in NVP's upstream emberjs#21221, simplifying side-by-side review.
|
FWIW: I made claude extract each change in this PR onto its own branch and ran them through TracerBench 20-fidelity compare (Krausest-style Hi @NullVoxPopuli-ai-agent — I spent some time extracting each change in this PR onto its own branch at johanrd/ember.js, running TracerBench (mostly 20-fid, some at 40-fid and 80-fid), and writing isolated mitata microbenches where the mechanism was testable in isolation. Each exploration PR carries both the isolated and end-to-end numbers so the contributions can be weighed independently. Hopefully this helps triage which pieces carry the reported perf win and which are free to drop to simplify review. Environment: MacBook Pro 14" (M1 Max), Node 24.14, Chrome Canary headless, Ember source built via Per-change summary
Findings worth calling outRow 1 — destroyable (merged)The one cleanly-attributable win. O(n²) → O(n) on child-teardown during a 5000-row clear. Row 2 — ArrayIterator flat state: silent correctness bugInitially looked like a −172 ms / −7.81 % win. Fix: read Row 5 — _iterTag: absorbed by destroyableSolo bench showed −55 ms / −2.47 %. Combined with destroyable in one branch: Rows 6, 7 — real isolated wins that Krausest can't seeMicrobenches show −71 % to −89 % in isolation; Krausest says null. The mechanism is real (wrapper allocations / stack-capture branch cost) but the per-render saving is < 1 % of Krausest's total, below its noise floor. Could matter for long-running apps that accumulate these over thousands of renders; hard to prove on any single-run bench. Row 10 — microbench wins turn into Krausest regressionOpposite lesson: microbench says Array-backed Row 9 — bi-modalMicrobench: +33 % regression on Krausest-like pure-iteration, −78 % to −84 % win on method-heavy patterns (computed properties derived from trackedArrays). Krausest null because the mix roughly cancels. Whether to ship depends on what real apps look like — I don't have that data. Row 13 — Math.max: Krausest positive, microbench negativeIsolated microbench: manual compare is 10–24 % slower than 80-fid Krausest: −1.41 % duration, tight CI, no regressions, wins distributed across render / clear / select phases. 20-fid and 40-fid earlier showed small +1–2 ms regressions that dissolved at 80-fid. I cannot cleanly explain the Krausest win if the mechanism itself is slower in isolation. Possible: smaller function body fits icache / inlines better, JIT tier-up timing at call sites, or the Krausest-80fid "win" is more subtle than the noise floor suggests. Flagging for your take. Row 14 — early-returns: pure nullAll 23 Krausest phases no-diff. Refactor-only. Could be accepted on readability grounds. Suggested actionWhat the bench supports landing: the destroyable change (row 1), already shipped as johanrd/ember.js#23 → merged into johanrd/main. Everything else is either:
Narrowing this PR to just the destroyable piece would capture the only clear win without risking row 2's correctness bug, and johanrd/ember.js#23 already has the attributed bench data if that's more useful to reference. Methodology caveat — the Krausest harness doesn't verify DOM state
Rule I'm applying now: never trust a bench win that hasn't been run through |

note from nvp: