Skip to content

[JSC] FTL: call operationToInt32 on the slow path of the x86_64 double to int32 conversion - #625

Open
robobun wants to merge 1 commit into
mainfrom
robobun/cc13cb88/ftl-to-int32-slow-path
Open

[JSC] FTL: call operationToInt32 on the slow path of the x86_64 double to int32 conversion#625
robobun wants to merge 1 commit into
mainfrom
robobun/cc13cb88/ftl-to-int32-slow-path

Conversation

@robobun

@robobun robobun commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • On x86_64, a build with assertions on aborts on ordinary numeric code as soon as the FTL compiles it. Example: for (let i = 0; i < n; ++i) int32Array[i & 7] = 1.5;. Release builds give the right value.
ASSERTION FAILED: exp >= 63
Source/JavaScriptCore/runtime/MathCommon.h(155) : int32_t JSC::toInt32AfterFailedTruncation(double)
  • sensibleDoubleToInt32 (ftl/FTLLowerDFGToB3.cpp:25462) calls operationToInt32SensibleSlow through callWithoutSideEffects, so B3 treats the call as pure. When the double is loop-invariant, hoistLoopInvariantValues moves the call from its rarely taken block to the loop pre-header. There it runs with a double that the truncation accepts, for example 1.5.
  • operationToInt32SensibleSlow only handles a double that the truncation rejected. Since 320644@main (in main through Upgrade to upstream WebKit ccdcb8a026 #614) it asserts that.

Fix

  • The FTL slow path calls operationToInt32. That operation accepts every double, so B3 can run it anywhere. The other FTL path, doubleToInt32WithLimits, already calls it the same way.
  • The cost is one more cvttsd2siq on a path that only NaN, the infinities and |x| >= 2^63 take.
  • The DFG keeps operationToInt32SensibleSlow. Its slow path generator runs only after the truncation failed, so the assertion holds there.
  • Verified: JSTests/stress/ftl-to-int32-loop-invariant-double.js (new) aborts on the jsc of the autobuild-cf1b36ec debug build with the ftl-eager-no-cjit options. It passes on the jsc of this PR's preview build (autobuild-preview-pr-625-fe20753d, debug and ASAN) with the same options, with the defaults, and with --useConcurrentJIT=0. Also to-int32-sensible.js, to-int32-sensible2.js and to-int32-out-of-int32-range-doubles.js. In Bun: Bump WebKit (oven-sh/WebKit#625 preview): stop assert-enabled builds aborting on a ToInt32 that B3 hoists out of a loop bun#42333 pins that preview build. Its test fails 7 of 8 cases on a debug build of main and passes with the pin.

Background

  • ToInt32 on x86_64: cvttsd2siq truncates the double to an int64, and the low 32 bits are the result. The instruction returns INT64_MIN for NaN, the infinities and |x| >= 2^63. Only then does the code take the slow path call.
  • callWithoutSideEffects emits a B3 CCall with Effects::none(). B3 may delete such a value, or move it to any point where its inputs exist.
  • hoistLoopInvariantValues is the loop-invariant code motion of B3. A value with no effects, and with all inputs defined outside the loop, moves to the loop pre-header. Unless it is controlDependent, it moves even from a block that does not run on every iteration.
Notes

Which code reaches it. The double has to be loop-invariant for B3 and not for the DFG. Otherwise the DFG folds the conversion, or hoists the whole ValueToInt32 node with its branch. Two families do that:

  • A store of a fractional double into an integer typed array in a loop: int32Array[j] = 1.5, uint8Array[i & 7] = 255.5. The conversion is part of PutByVal, so the DFG has no node to fold.
  • A double made from a value that only B3 folds: zero = Math.imul(0, i) (also i - i, (i * 0) | 0), then (zero + 0.5) | 0, (zero ** 2) >> 0, ~(zero ** 2).

Release builds. The B3 dump of a release build (dumpB3GraphAtEachPhase) shows the CCall in the pre-header after hoistLoopInvariantValues, with the constant double as its argument. Its result only reaches the Phi through the Upsilon of the slow block, which does not run. function hot(i) { const zero = i - i; return (zero + 5.5) | 0; } summed over 3e6 iterations gives 15000000 on a release build at this commit.

What hides it. --useB3HoistLoopInvariantValues=0, --useFTLJIT=0, --useDFGJIT=0.

Upstream. WebKit/WebKit main has the same code in MathCommon.h, FTLLowerDFGToB3.cpp and B3HoistLoopInvariantValues.cpp, and the same option default, so a debug build of upstream JSC aborts the same way.

Alternatives.

  • Remove the ASSERT. The helper returns 0 for every |x| < 2^52, so a pure operation would stay registered that gives a wrong ToInt32 for most doubles. Nothing reads that result today. The assertion is also right for the two callers that do check first (toInt32 and the DFG).
  • Mark the CCall controlDependent. That keeps the smaller helper and stops the hoist. It needs a new way to build a CCallValue with custom effects in FTL::Output, for a path that almost never runs.

The test. Every loop uses testLoopCount, so only the eager configurations reach the FTL. Before the fix, all 11 fast path conversions and all 6 fast path typed array stores abort one by one under the ftl-eager-no-cjit options. The slow path inputs (2 ** 63 + 2048, NaN, the infinities) do not abort. They check the value of a hoisted call whose result is used. The file passes on the unfixed jsc with --useFTLJIT=0, with --useJIT=0, and with --useB3HoistLoopInvariantValues=0. With testLoopCount replaced by 10000 it passes on node v26.3.0.

Run time. JSTests/README.md asks for less than 200 ms in every configuration. On the release jsc of the preview build (bun-webkit-linux-amd64), best of 3 runs: default 33 ms, no-llint 20 ms, no-cjit-validate-phases 49 ms, ftl-no-cjit-b3o0 18 ms, ftl-eager 51 ms, ftl-eager-no-cjit 124 ms, ftl-eager-no-cjit-b3o1 117 ms, dfg-eager 31 ms, no-ftl 28 ms, --useJIT=0 8 ms.

…e to int32 conversion

sensibleDoubleToInt32() emits the call on its slow path with
callWithoutSideEffects(), so B3 may run that call before the branch that guards
it. hoistLoopInvariantValues does so when the double is loop-invariant: the call
moves to the loop pre-header and runs with a double that the truncation accepts.

operationToInt32SensibleSlow is only defined for a double that the truncation
rejected. Since 320644@main it asserts that, ASSERT(exp >= 63) in
toInt32AfterFailedTruncation(), so a build with assertions on aborts as soon as
the FTL compiles a loop such as

    for (let i = 0; i < n; ++i) int32Array[i & 7] = 1.5;

A release build runs the call and drops its result.

Call operationToInt32 there. It accepts every double, and the other FTL path
already calls it the same way. The DFG keeps operationToInt32SensibleSlow: its
slow path generator runs only after the truncation failed.
@github-actions

Copy link
Copy Markdown

Preview Builds

Commit Release Date
fe20753d autobuild-preview-pr-625-fe20753d 2026-09-11 14:48:56 UTC

@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 FTL codegen on a path where B3's effect model determines correctness, and both touched paths are CODEOWNER-gated (@ WebKit/jsc-reviewers), a human look is still worthwhile.

What was reviewed

  • Confirmed operationToInt32 has the same signature (UCPUStrictInt32 (double)) as operationToInt32SensibleSlow and wraps JSC::toInt32, which is defined for every double — safe to call with Effects::none() after a hoist.
  • Checked the sibling doubleToInt32WithLimits path already uses operationToInt32 via callWithoutSideEffects, so this aligns both slow paths.
  • Verified the remaining operationToInt32SensibleSlow caller (DFG SpeculativeJIT) goes through a slow-path generator that runs only after the truncate check fails, so its ASSERT(exp >= 63) still holds.
  • Checked the new stress test against JSTests/README.md rules: throws on failure, uses testLoopCount, no logging, one new Function per case so each loop tiers up independently.
Extended reasoning...

Overview

The PR is a two-file change: a one-token functional edit in Source/JavaScriptCore/ftl/FTLLowerDFGToB3.cpp (swapping operationToInt32SensibleSlowoperationToInt32 on the x86_64 sensibleDoubleToInt32 slow path, plus a comment), and a new regression test at JSTests/stress/ftl-to-int32-loop-invariant-double.js. The root cause is well-argued: the slow-path CCall is emitted with Effects::none() via callWithoutSideEffects, so B3's hoistLoopInvariantValues may lift it above the fastResult64 == INT64_MIN guard, at which point toInt32AfterFailedTruncation's ASSERT(exp >= 63) (MathCommon.h:155) fires on ordinary doubles in debug builds. Swapping to the total function operationToInt32 (which just calls JSC::toInt32) makes the pure-call contract sound regardless of placement.

Security risks

None identified. This is a debug-assert fix; release builds already produced correct results because the hoisted call's value only feeds the slow-path Upsilon, which isn't selected when the fast branch wins. There's no new attack surface — the replacement callee is an existing, already-JIT-callable pure math operation with an identical ABI, and the added test is data-only JS with no I/O.

Level of scrutiny

Medium-high. The edit itself is tiny and mirrors the adjacent doubleToInt32WithLimits path, but it lives in FTL lowering where getting the effect/purity model wrong can silently miscompile hot code. The reasoning about B3 hoisting pure calls past control dependence is subtle enough that a JSC reviewer should confirm the chosen fix (total callee) over the alternatives the author lists (marking the call controlDependent, or dropping the assert). CODEOWNERS routes both /Source/JavaScriptCore and /JSTests to @ WebKit/jsc-reviewers, which by itself is reason not to auto-approve.

Other factors

The new stress test looks well-constructed: it uses testLoopCount, fails by throwing, avoids logging, and generates a fresh Function per case so each loop can tier into FTL independently. It covers both the bitwise-op path and the typed-array-store path, and includes slow-path inputs (2**63 + 2048, NaN, infinities) whose hoisted result is observed — a nice check that operationToInt32 returns the right value there. I could not verify the "< 200ms in all configurations" requirement from JSTests/README.md without a build; with ~32 new Function bodies each looping testLoopCount times, that's worth spot-checking with --report-execution-time before merge.

@coderabbitai

coderabbitai Bot commented Sep 11, 2026

Copy link
Copy Markdown

Review Change StackReview 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: b6ebe2bb-5f79-46fc-937b-381484fd9e73

📥 Commits

Reviewing files that changed from the base of the PR and between cf1b36e and fe20753.

📒 Files selected for processing (2)
  • JSTests/stress/ftl-to-int32-loop-invariant-double.js
  • Source/JavaScriptCore/ftl/FTLLowerDFGToB3.cpp

Included review availability: Your plan provides up to 5 included reviews per hour; 0 remain after this review.


Walkthrough

The FTL slow path now calls operationToInt32 for hoist-safe double conversion. A stress test covers loop-invariant conversions, exceptional and boundary values, arithmetic expressions, and typed-array stores.

Changes

FTL int32 conversion

Layer / File(s) Summary
Hoist-safe conversion and stress validation
Source/JavaScriptCore/ftl/FTLLowerDFGToB3.cpp, JSTests/stress/ftl-to-int32-loop-invariant-double.js
The slow path uses operationToInt32, and the stress test validates fast and slow conversions, boundary values, exceptional doubles, accumulated results, and typed-array stores.

Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to fe207

The conversion change preserves correct behavior when optimization hoists the slow-path call, with stress coverage for affected values and stores. No actionable merge risk remains.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
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.
Description check ✅ Passed The description clearly explains the assertion failure, root cause, fix, affected paths, alternatives, test coverage, and runtime results. It does not include the Bugzilla link or the standard review …
Title check ✅ Passed The title is concise, specific, and accurately identifies the x86_64 FTL double-to-int32 slow-path change.

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.


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

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