-
-
Notifications
You must be signed in to change notification settings - Fork 161
perf(runtime): resolve Array.prototype.map's result header past 64 elements (−54%) #10577
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
Closed
+186
−15
Closed
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| `Array.prototype.map` filling a plain result array only took the once-resolved | ||
| header fast path (`fill_resolved_array_slot`, from the earlier map fill | ||
| change) for a source of at most 64 elements; a longer source fell back to | ||
| `note_array_slot`, which re-classifies the result's ownership/forwarding | ||
| through `clean_arr_ptr` (`array_numeric_layout`) and unconditionally pays | ||
| `layout_note_slot`, on every element. `result` is re-derived from the result's | ||
| own GC root immediately after the callback returns and before either helper | ||
| runs, so the "no intervening allocation or safepoint" contract | ||
| `fill_resolved_array_slot` needs holds regardless of length — the 64-element | ||
| split was scope, not a correctness boundary. It now applies unconditionally. | ||
|
|
||
| Also dropped a redundant raw `ptr::write` of the mapped value that ran | ||
| immediately before both branches — both `fill_resolved_array_slot` and | ||
| `note_array_slot` perform their own (possibly canonicalized) store of the same | ||
| slot, so the first write was always immediately overwritten. | ||
|
|
||
| `a.map(x => x + v)` over a 16-vs-80-element `number[]` (measured as the | ||
| marginal per-call cost difference of two probes differing only in element | ||
| count, per element, N=20000, median of 7): 450.8 -> 206.1 instructions per | ||
| element (-54.3%), control `(loop80-loop16)/64` reads -0.39 and +0.08 in the | ||
| before/after arms respectively. | ||
|
|
||
| New fixture `test-files/test_gap_array_map_resolved_fill_scale.ts` exercises | ||
| sources both under and over the old 64-element boundary and the ~2048-element | ||
| born-old allocation threshold: a callback returning non-numeric values | ||
| (retiring the raw-f64 numeric claim mid-fill), one that allocates heavily to | ||
| force collections between the header resolve and the store, one that pushes | ||
| to the source mid-fill, one that truncates it mid-fill, a sparse/holey source, | ||
| and a plain numeric control. Matches node 26.5.1 and passes under seeded | ||
| moving-GC stress (`PERRY_GC_SCHEDULE_SEED`/`PERRY_GC_PROTECT_FROMSPACE`). |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,146 @@ | ||
| // `Array.prototype.map`'s plain-array fill used to resolve the result | ||
| // array's head once per element (avoiding a re-classification of the same | ||
| // pointer through `clean_arr_ptr`/`array_numeric_layout`) only for a source | ||
| // of at most 64 elements; longer sources fell back to the fully | ||
| // re-classifying `note_array_slot`. This fixture pins the fast path across | ||
| // that former boundary: sources both under and well over 64 (and over the | ||
| // ~2048-element / 16KB born-old allocation threshold), with callbacks | ||
| // designed to attack the specific risk of resolving the result header once | ||
| // per element instead of proving it fresh every store — a callback that | ||
| // allocates (forcing a collection between the resolve and the store), that | ||
| // returns non-numeric values (retiring the raw-f64 numeric claim mid-fill), | ||
| // that mutates the SOURCE by growing or truncating it out from under the | ||
| // still-running loop, and a sparse/holey source (skips must still land at | ||
| // the right index in the result). | ||
|
|
||
| function range(n: number): number[] { | ||
| const a: number[] = []; | ||
| for (let i = 0; i < n; i++) a.push(i); | ||
| return a; | ||
| } | ||
|
|
||
| // ---- control: plain numeric map, both sides of the old 64 cap ----------- | ||
|
|
||
| const small = range(16); | ||
| console.log("ctrl-small", JSON.stringify(small.map((v) => v + 1))); | ||
|
|
||
| const mid = range(65); // one past the old cap | ||
| console.log("ctrl-mid-sum", mid.map((v) => v * 2).reduce((a, b) => a + b, 0)); | ||
| console.log("ctrl-mid-ends", JSON.stringify([mid.map((v) => v * 2)[0], mid.map((v) => v * 2)[64]])); | ||
|
|
||
| const big = range(500); | ||
| const bigMapped = big.map((v) => v * 3 + 1); | ||
| console.log("ctrl-big", bigMapped.length, bigMapped[0], bigMapped[250], bigMapped[499]); | ||
| console.log("ctrl-big-sum", bigMapped.reduce((a, b) => a + b, 0)); | ||
|
|
||
| // Past the born-old allocation threshold (~2048 elements / 16KB of f64s): | ||
| // the RESULT array itself starts life in the old generation. | ||
| const huge = range(5000); | ||
| const hugeMapped = huge.map((v) => v + 0.25); | ||
| console.log( | ||
| "ctrl-huge", | ||
| hugeMapped.length, | ||
| hugeMapped[0], | ||
| hugeMapped[2048], | ||
| hugeMapped[4999], | ||
| hugeMapped.reduce((a, b) => a + b, 0), | ||
| ); | ||
|
|
||
| // ---- non-numeric return kinds: retires the raw-f64 numeric claim -------- | ||
|
|
||
| const kindsSrc = range(200); | ||
| const kindsMapped = kindsSrc.map((v) => { | ||
| if (v % 5 === 0) return `s${v}`; | ||
| if (v % 5 === 1) return { v }; | ||
| if (v % 5 === 2) return undefined; | ||
| if (v % 5 === 3) return v % 2 === 0; | ||
| return v * 1.5; | ||
| }); | ||
| console.log( | ||
| "kinds", | ||
| kindsMapped.length, | ||
| typeof kindsMapped[0], | ||
| typeof kindsMapped[1], | ||
| typeof kindsMapped[2], | ||
| kindsMapped[2], | ||
| typeof kindsMapped[3], | ||
| typeof kindsMapped[4], | ||
| kindsMapped[4], | ||
| ); | ||
| console.log("kinds-json", JSON.stringify(kindsMapped)); | ||
|
|
||
| // -0 / NaN survive the fast path exactly. | ||
| const zeroNan = range(100).map((v) => (v === 0 ? -0 : v === 1 ? NaN : v)); | ||
| console.log("zero-nan", Object.is(zeroNan[0], -0), zeroNan[1] !== zeroNan[1], zeroNan[99]); | ||
|
|
||
| // ---- callback allocates heavily: forces collections mid-fill ------------ | ||
|
|
||
| const allocSrc = range(300); | ||
| const allocMapped = allocSrc.map((v) => { | ||
| const junk = new Array(48).fill({ v, pad: [v, v, v] }); | ||
| let s = 0; | ||
| for (const j of junk) s += j.v; | ||
| return s + v; | ||
| }); | ||
| console.log("alloc-len", allocMapped.length, allocMapped[0], allocMapped[149], allocMapped[299]); | ||
| console.log("alloc-sum", allocMapped.reduce((a, b) => a + b, 0)); | ||
|
|
||
| // ---- callback pushes to the source mid-fill ------------------------------ | ||
|
|
||
| const pushSrc = range(120); | ||
| const pushMapped = pushSrc.map((v, i) => { | ||
| if (i < 10) pushSrc.push(1000 + i); | ||
| return v; | ||
| }); | ||
| console.log("push-mapped-len", pushMapped.length, JSON.stringify(pushMapped.slice(0, 5))); | ||
| console.log("push-mapped-tail", pushMapped[119]); | ||
| console.log("push-src-len", pushSrc.length, pushSrc[120], pushSrc[129]); | ||
|
|
||
| // ---- callback truncates the source mid-fill ------------------------------ | ||
|
|
||
| const truncSrc = range(150); | ||
| const truncMapped = truncSrc.map((v, i) => { | ||
| if (i === 20) truncSrc.length = 60; | ||
| return v; | ||
| }); | ||
| console.log("trunc-mapped-len", truncMapped.length); | ||
| console.log("trunc-mapped-json-head", JSON.stringify(truncMapped.slice(0, 25))); | ||
| console.log("trunc-mapped-holes", 100 in truncMapped, 61 in truncMapped, 59 in truncMapped); | ||
| console.log("trunc-src-len", truncSrc.length); | ||
|
|
||
| // ---- sparse / holey source, well past the old 64-element cap ------------ | ||
|
|
||
| const holey: number[] = []; | ||
| holey.length = 200; | ||
| for (let i = 0; i < 200; i++) { | ||
| if (i % 7 !== 0) holey[i] = i; | ||
| } | ||
| const holeyMapped = holey.map((v) => v * 10); | ||
| console.log( | ||
| "holey-len", | ||
| holeyMapped.length, | ||
| 0 in holeyMapped, | ||
| 7 in holeyMapped, | ||
| 14 in holeyMapped, | ||
| 1 in holeyMapped, | ||
| holeyMapped[1], | ||
| holeyMapped[199], | ||
| ); | ||
| console.log("holey-json", JSON.stringify(holeyMapped.slice(0, 16))); | ||
|
|
||
| // A fully empty-but-long holey source: every index skipped. | ||
| const allHoles: number[] = new Array(90); | ||
| const allHolesMapped = allHoles.map((v) => v + 1); | ||
| console.log("all-holes-len", allHolesMapped.length, JSON.stringify(Object.keys(allHolesMapped))); | ||
|
|
||
| // ---- frozen source, past the old cap ------------------------------------- | ||
|
|
||
| const frozenBig = Object.freeze(range(90)); | ||
| console.log("frozen-big", JSON.stringify(frozenBig.map((v) => v + 1)).length, frozenBig.map((v) => v + 1)[89]); | ||
|
|
||
| // ---- object-identity payloads mixed with numbers, past the old cap ------ | ||
|
|
||
| const tag = { name: "shared" }; | ||
| const identitySrc = range(80); | ||
| const identityMapped = identitySrc.map((v) => (v === 40 ? tag : v)); | ||
| console.log("identity", identityMapped[40] === tag, identityMapped[39], identityMapped[41]); | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: PerryTS/perry
Length of output: 8242
🏁 Script executed:
Repository: PerryTS/perry
Length of output: 2821
🏁 Script executed:
sed -n '190,225p' crates/perry-runtime/src/array/header_gc_slots.rsRepository: PerryTS/perry
Length of output: 1635
🏁 Script executed:
sed -n '225,245p' crates/perry-runtime/src/array/header_gc_slots.rsRepository: PerryTS/perry
Length of output: 1182
Correct the collection timing description.
The callback allocates heavily and can trigger collection during
cb_site.call. After the callback returns,js_array_mapre-derivesresultfromresult_rootedand callsfill_resolved_array_slotwithout another callback or fixture-controlled allocation. The fixture cannot force collection between result re-derivation and the store.Update the fixture comment and changelog to describe collection as occurring before result-pointer re-derivation. Remove the claim that the fixture collects between header resolution and storage.
🤖 Prompt for AI Agents