Skip to content

[JSC] PutCellButterflySlot forbids the double format for the variable it stores - #792

Merged
dylan-conway merged 3 commits into
mainfrom
robobun/e7f56da0/inlined-sort-keeps-element-values
Oct 16, 2026
Merged

dylan-conway merged 3 commits into
mainfrom
robobun/e7f56da0/inlined-sort-keeps-element-values

Conversation

@robobun

@robobun robobun commented Oct 11, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

An inlined Array#sort of an Int32 array can return its elements out of order, as boxed doubles.

function f(v, w, x, y) { return [v, w, x, y].sort((a, b) => (a + 0.5) - (b + 0.5)); }
noInline(f);
var r;
for (var i = 0; i < 20000; i++) r = f(17, 8, 1, 1);
print(r[0] + "," + r[1] + "," + r[2] + "," + r[3], r.join(), r.indexOf(8));
jsc output
main, Release 8,1,1,17 0,0,0,17 -1
main, assertions ASSERTION FAILED: result.isInt32() || !result in JSObject::tryGetIndexQuickly
this branch 1,1,8,17 1,1,8,17 2

In Bun 1.4.3 the same function returns a wrong array in 197,490 of 200,000 calls.

Cause

  • handleArraySort keeps the pivot of its insertion sort in a DFG variable (tmpPivot, DFGByteCodeParser.cpp:14220) and stores it back with PutCellButterflySlot (:14339), which writes the bits of the JSValue as they are.
  • For an Int32 array, GetCellButterflySlot is typed SpecInt32Only with no check, and ArraySortCommit copies the scratch into the array as is. So every store into the scratch has to be a boxed Int32.
  • The comparator is inlined, so its first parameter is the GetLocal of the pivot. Double arithmetic on it votes the variable into the double format. Main then has SetLocal(DoubleRep, tmp2(MC<Number>/FlushedDouble)) and a ValueRep(DoubleRep) in front of the store, and a boxed double goes into the scratch.

Fix

doDoubleVoting merges CantUseDoubleFormat into the variable that a PutCellButterflySlot stores. The pivot is then tmp2(MC~<Int32>/FlushedJSValue), and the store takes its GetLocal directly.

For a Contiguous array the rule has no effect: the pivot is predicted SpecBytecodeTop, and only a variable with a number prediction can take the double format.

Why this is a JSC bug

  • What does Bun do that JSC does not like? Nothing. The program is plain JavaScript and fails in the jsc shell.
  • Why does upstream not have the issue? By its source, it has it. The inlined sort is upstream's (bug 313668). handleArraySort is line for line the same here and on WebKit main f71518e (2026-10-16). The only difference of the fork in double voting is a case for BufferWrite. No upstream commit after the fork's merge point (23b0720) touches GetCellButterflySlot, PutCellButterflySlot, ArraySortCompact, ArraySortCommit or handleArraySort. This was compared by source. No upstream binary was run.
  • The change has no USE(BUN_JSC_ADDITIONS) guard.

Verification

jsc of main b1e30d0 and of this branch on it, Linux x64, Release with and without assertions.

main this branch
array-sort-inline-int32-double-comparator.js, 17 configurations 14 fail 17 pass
every stress test with sort in its name (43 files, 667 runs), assertions 14 fail, all in the new test 667 pass
the same 667 runs, Release 667 pass
probe: wrong rows of 328 102 0
  • On main the new test fails with bad value: 0,0,0,17 expected: 1,1,8,17. It passes on main in lockdown, no-cjit-validate-phases and ftl-no-cjit-no-inline-validate. Slowest configuration on this branch: 17 ms (Release), 35 ms (assertions).
  • Sorts that are right on main keep their DFG node lists and their instruction counts (details below).
Measurements

Probe. 41 comparators by 8 array lengths (2, 3, 4, 5, 8, 15, 16, 17) give 328 rows. A row is 3,000 sorts of random Int32s. Each result is compared with the result of a copy of the function that has the DFG off. In the 64 rows whose comparator is not consistent, where the order is up to the implementation, the check is that the result has the same elements and that they are Int32s.

  • Main has 102 wrong rows, with --useConcurrentJIT=0 and with --useConcurrentJIT=0 --useFTLJIT=0.
  • This branch has 0 wrong rows in each of 9 option sets:
    • assertions: default options; --useConcurrentJIT=0 alone, and with each of --useFTLJIT=0, --validateAbstractInterpreterState=1, --validateGraphAtEachPhase=1 --validateGraph=1, --doubleVoteRatioForDoubleFormat=0, --useFTLJIT=0 --doubleVoteRatioForDoubleFormat=0.01
    • Release: --useConcurrentJIT=0 with tier-up thresholds of 10 and 20, and with --doubleVoteRatioForDoubleFormat=0
  • With a vote ratio of 0, value votes do not keep a number variable out of the double format.

Sorts that are right on main. The only code that tells CantUseDoubleFormat from NotUsingDoubleFormat is ArgumentPosition, which merges the states of the variables of an argument. The pivot is a private tmp and is in no argument position.

The 41 comparators at lengths 4 and 8 give 82 rows per tier. The node lists come from --dumpDFGDisassembly=1 and --dumpFTLDisassembly=1, with addresses and structure names masked, 5 runs per binary, most frequent list compared.

DFG FTL
rows where main stores a ValueRep(DoubleRep) 42 20
rows where this branch does 0 0
of those rows of main, node list differs from this branch 42 20
other rows 40 62
of the other rows, node list equal to this branch 40 44

The 18 other FTL rows are all of length 8, where runs of one binary differ from each other too (the most frequent list of main has 2 to 4 of 5 runs). 13 of the 18 have no inlined sort in their FTL code. For the other 5, 14 runs per binary give 3 to 7 distinct lists per binary, and 3 or 4 of them occur in both.

Instructions in user space (perf stat, millions, least of 3 runs in rotated order) for 3,000,000 sorts of 8 Int32s in reverse order, --useConcurrentJIT=0. "main again" is a copy of the same binary.

comparator, tier main main again this branch
x - y, DFG 6,301.5 6,301.0 6,301.2
x - y, FTL 2,380.6 2,380.7 2,379.8
K[x] - K[y], DFG 7,393.8 7,393.8 7,394.1
K[x] - K[y], FTL 3,411.6 3,411.2 3,411.7

Sorts that change. (x + 0.5) - (y + 0.5) over 8 Int32s in reverse order, where an insertion sort needs 28 comparisons:

main this branch
wrong results of 300,000 sorts 299,901 0
comparator calls per sort 13.00 27.99
instructions for 3,000,000 sorts, DFG (millions) 3,874.7 7,036.6
instructions for 3,000,000 sorts, FTL (millions) 2,072.1 3,041.7

Main runs fewer instructions because it makes fewer comparisons.

Alternative. One line in the parser, mergeShouldNeverUnbox(true) on the pivot, also has 0 wrong rows of 328. It changes the DFG code of sorts that are right on main: 6,280.3 for x - y (-0.3%) and 7,456.8 for K[x] - K[y] (+0.9%) in the table above. In the FTL it gives 2,382.7 and 3,411.2.

… it stores

The DFG and the FTL inline Array.prototype.sort for at most 16 elements
(ByteCodeParser::handleArraySort). The insertion sort keeps its pivot in a
variable, passes it to the inlined comparator as the first argument, and
stores it back into the scratch with PutCellButterflySlot. That store writes
the value as is, the loop reads the scratch of an Int32 array back as Int32s
with no check, and ArraySortCommit copies the scratch as is into the array.

A comparator that does double arithmetic on its first parameter makes double
voting give that variable the double format. The pivot of an Int32 array then
goes back into the scratch as a boxed double:

    function f(v, w, x, y) { return [v, w, x, y].sort((a, b) => (a + 0.5) - (b + 0.5)); }

Once the DFG has compiled f, f(17, 8, 1, 1) returns the elements out of
order, as boxed doubles in an ArrayWithInt32. Element reads give 8,1,1,17,
join() gives "0,0,0,17" and indexOf(8) gives -1. After the first such store
the comparator also gets numbers that are not in the array. FTL code for f
aborts under --validateAbstractInterpreterState=1.

Double voting now merges CantUseDoubleFormat into the variable that a
PutCellButterflySlot stores. Only the double format changes the bits that go
back: a variable that holds an Int32 or a JSValue gives back the value that
came out of the array. The pivot of such a sort is now a JSValue, and the
comparator converts it where it uses it. A sort whose pivot was not in the
double format gets the code it got before.

The inlined sort is upstream's (WebKit#63908), and upstream main
returns the same arrays.

* JSTests/stress/array-sort-inline-int32-double-comparator.js: Added.
* Source/JavaScriptCore/dfg/DFGPredictionPropagationPhase.cpp:
@robobun

robobun commented Oct 11, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review.

How to reproduce (Release jsc shell of main):

function f(v, w, x, y) { return [v, w, x, y].sort((a, b) => (a + 0.5) - (b + 0.5)); }
noInline(f);
var r;
for (var i = 0; i < 20000; i++) r = f(17, 8, 1, 1);
print(r[0] + "," + r[1] + "," + r[2] + "," + r[3], r.join(), r.indexOf(8));
  • main prints 8,1,1,17 0,0,0,17 -1.
  • This branch prints 1,1,8,17 1,1,8,17 2.
  • In Bun, the same function called 200,000 times prints 0,0,0,17 for join() on 1.4.0 to 1.4.3 and 1,1,8,17 on 1.3.14.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and didn't find any bugs; because it changes DFG double-format voting (a tier-wide compiler correctness decision) and I could not run the new stress test in this checkout, a human JSC look is still worthwhile.

What was reviewed:

  • Confirmed child3 is the stored value for both PutCellButterflySlot sites in handleArraySort; the pivot store at DFGByteCodeParser.cpp:14253 is a GetLocal in a fresh block (no earlier SetLocal of tmpPivot there, so CPS rethreading cannot replace it), and the shift store at :14237 stores a GetCellButterflySlot result, not a local.
  • Traced how the pivot gets double votes: the inlined comparator's get() of its first argument resolves to the same GetLocal(tmpPivot) node in-block, so ArithAdd votes land on the pivot's VariableAccessData; merging CantUseDoubleFormat there is checked before tallyVotes in the same round and is idempotent across rounds.
  • Checked the voteChildren call: child1 (scratch cell) gets a harmless VoteValue, child2 (ArithAdd) is a no-op in voteNode, matching the previous default-case behavior.
  • Unconditional application (no array-mode gate) only costs a per-call DoubleRep in the comparator; it cannot produce wrong results for Double/Contiguous scratches.
Extended reasoning...

The change adds one case to doDoubleVoting in Source/JavaScriptCore/dfg/DFGPredictionPropagationPhase.cpp that forbids the double flush format for a local stored by PutCellButterflySlot, plus a new stress test in JSTests/stress; it touches no security-sensitive surface (no auth, injection, or data exposure). The logic is small and consistent with the parser's node shape and the VariableAccessData state machine, and it is only reachable from the inlined Array.prototype.sort expansion. It is not approved outright because it is a JIT type-inference correctness fix whose effect depends on how later phases (fixup, CPS/SSA conversion) consume the flush format, no jsc build exists in this checkout to confirm the test fails before and passes after, and the changed paths fall under the /Source/JavaScriptCore and /JSTests CODEOWNERS entries.

@github-actions

github-actions Bot commented Oct 11, 2026 •

Copy link
Copy Markdown

Preview build of 252ca22: autobuild-preview-pr-792-252ca228

@coderabbitai

coderabbitai Bot commented Oct 11, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: ASSERTIVE
  • Plan: Essentials
  • Run ID: 9175abbe-21c1-40ec-ac3f-08f8bf3488db
📥 Commits

Reviewing files that changed from the base of the PR and between 5d2ceba and f8dd0d7.

📒 Files selected for processing (2)
  • JSTests/stress/array-sort-inline-int32-double-comparator.js
  • Source/JavaScriptCore/dfg/DFGPredictionPropagationPhase.cpp

Included review availability: This review used your included allowance. Your plan provides up to 5 included reviews per hour; 3 remain after this review.

Walkthrough

The DFG prediction pass now handles PutCellButterflySlot values during double voting. A stress test compares DFG-optimized sort results with non-DFG reference results for Int32 arrays and double-arithmetic comparators.

Changes

Int32 Array Sort Comparator Prediction

Layer / File(s) Summary
Butterfly slot predictions and sort regression test
Source/JavaScriptCore/dfg/DFGPredictionPropagationPhase.cpp, JSTests/stress/array-sort-inline-int32-double-comparator.js
The prediction pass votes PutCellButterflySlot children as values and marks a stored GetLocal variable as unable to use double format. The stress test compares optimized sort results with non-DFG reference results for two double-arithmetic comparators.

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to f8dd0

No actionable issue is established. The change is mergeable subject to normal checks.

Pre-merge checks: Passed 4
Check name Status Explanation
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Title check Passed The title clearly identifies the primary fix: preventing PutCellButterflySlot from allowing the double format for the stored variable.
Description check Passed The description is detailed and directly explains the bug, cause, fix, affected files, reproduction, and verification results. It does not include the required Bugzilla URL, Reviewed by NOBODY marker,…
Full details: Description check

Explanation

The description is detailed and directly explains the bug, cause, fix, affected files, reproduction, and verification results. It does not include the required Bugzilla URL, Reviewed by NOBODY marker, or a template-formatted changed-file list, but the core information is complete.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Warning

Git: CodeRabbit could not clone the repository, so clone-backed analysis was skipped and this review may be incomplete. Verify repository clone access, such as SSH credentials, before requesting another full review. If clone access is intentionally unavailable, use path_filters to narrow the review scope.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Oct 14, 2026

Copy link
Copy Markdown
Collaborator Author

A second pull request needs this change under it: #806, the cherry-pick of WebKit#76845.

With #806 the division in !(a / 4) | 0 is a double division. A comparator with that shape then makes the pivot a double, as (a + 0.5) - (b + 0.5) does on main.

function f(v, w, x, y) { return [v, w, x, y].sort((a, b) => (!(a / 4) | 0) - (!(b / 4) | 0)); }

20,000 calls of f (noInline, --useConcurrentJIT=0, Release jsc):

build results that are not a permutation of the inputs
main 4b724e9 0
#806 (8e3e733) 19,893
#806 with the commit of this pull request 0

With this change under #806 the results are also equal to the interpreter's (--useJIT=0).

Upstream main has both WebKit#76149 and WebKit#76845 now. The next upstream sync brings the two changes with or without #778 and #806.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed the updated version (merge of main plus the shortened test and comment) and found no bugs; since this is a DFG/FTL correctness fix in the inlined Array#sort path, a maintainer look is still worthwhile.

What was reviewed:

  • Confirmed child3 is the value operand of PutCellButterflySlot at both parser sites (DFGByteCodeParser.cpp:14323 and :14339) and the pivot site always emits a fresh GetLocal after clearCaches, so the GetLocal check covers the only case where a DFG variable is stored.
  • Confirmed the merge to CantUseDoubleFormat runs inside doDoubleVoting before tallyVotesForShouldUseDoubleFormat in every round, uses find() for unified variables, and the inlined sort only supports Int32/Contiguous (no Double) array modes, so the rule is safe for all inlined shapes.
  • The shortened test no longer has the //@ runNoCJIT directive and no longer compares against a noDFG reference; I could not run it here, so whether this final form still fails on a build without the fix is worth a quick check.
Extended reasoning...

The change adds one case to the double-voting switch in Source/JavaScriptCore/dfg/DFGPredictionPropagationPhase.cpp that votes VoteValue for PutCellButterflySlot children and forbids the double format for a GetLocal stored as the value, plus a 14-line stress test. It touches no security-sensitive surface; it is a JIT type-format correctness fix shared by the DFG and FTL tiers. The logic is small and I verified the operand position, the voting-round ordering, and the supported array modes against the parser and backend code, but it sits in JSC compiler internals and the simplified test's ability to reproduce the bug could not be confirmed without a build, so a human look is the better call than an automated approve.

@dylan-conway
dylan-conway merged commit 87ee10d into main Oct 16, 2026
51 of 52 checks passed
dylan-conway added a commit to oven-sh/bun that referenced this pull request Oct 16, 2026
Bumps `WEBKIT_VERSION` from `b1e30d08cb14` to `87ee10da087b` (current
oven-sh/WebKit `main`). The
`autobuild-87ee10da087b63e011bd1ada64d1814a850a4395` release exists.

oven-sh/WebKit changes picked up:

- [JSC] PutCellButterflySlot forbids the double format for the variable
it stores (oven-sh/WebKit#792)

Checked locally on a debug build, whose `process.versions.webkit` is
`87ee10da087b63e011bd1ada64d1814a850a4395`: `[17, 8, 1, 1]` passed as
four arguments to `(v, w, x, y) => [v, w, x, y].sort((a, b) => (a + 0.5)
- (b + 0.5))` comes back as `1,1,8,17` in 200,000 of 200,000 calls. Bun
1.4.3 returns a wrong array in 192,141 of them. Everything else is left
to CI.
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.

2 participants