WebKit Bugzilla
New
Browse
Search+
Log In
×
Sign in with GitHub
or
Remember my login
Create Account
·
Forgot Password
Forgotten password account recovery
RESOLVED FIXED
312689
BBQ JIT Constant Folding Signed Zero Mishandling in `computeFloatingPointMinOrMax`
https://bugs.webkit.org/show_bug.cgi?id=312689
Summary
BBQ JIT Constant Folding Signed Zero Mishandling in `computeFloatingPointMinO...
parkjuny
Reported
2026-04-18 13:08:49 PDT
Created
attachment 479203
[details]
poc.js ## Summary The `computeFloatingPointMinOrMax` function in the BBQ JIT (`WasmBBQJIT.h:1679`) uses `std::min`/`std::max` for constant folding of `f32.min`, `f32.max`, `f64.min`, and `f64.max`. Because `+0.0 == -0.0` in IEEE 754, `std::min(+0.0, -0.0)` returns `+0.0` and `std::max(-0.0, +0.0)` returns `-0.0` — both wrong. This produces an observable **IPInt vs BBQ discrepancy**: the same Wasm functions return correct results before tier-up (IPInt interpreter) and wrong results after (BBQ JIT). ## Bug ### Detail The buggy function is `computeFloatingPointMinOrMax` at `WasmBBQJIT.h:1679–1691`: ```cpp // Source/JavaScriptCore/wasm/WasmBBQJIT.h:1679-1691 template<MinOrMax IsMinOrMax, typename FloatType> constexpr FloatType computeFloatingPointMinOrMax(FloatType left, FloatType right) { if (std::isnan(left)) return left; if (std::isnan(right)) return right; if constexpr (IsMinOrMax == MinOrMax::Min) return std::min<FloatType>(left, right); // BUG: returns left when left==right else return std::max<FloatType>(left, right); // BUG: returns left when left==right } ``` This is the **constant folding** path, invoked via `EMIT_BINARY` (`WasmBBQJIT.h:1576`) when both operands are compile-time constants. All four operations use it (`WasmBBQJIT.cpp:2325–2387`). The baked-in wrong constant is returned on every call after tier-up. The **runtime path** (`emitFloatingPointMinOrMax`, `WasmBBQJIT.cpp:2257`) is correct: on ARM64 it uses native `floatMin`/`doubleMin`/`floatMax`/`doubleMax` JIT operations; on other architectures it uses bitwise OR (min) and AND (max) on equal operands to correctly yield −0 and +0 respectively. A correct reference implementation already exists in `MathCommon.h:285–302` (`fMax` at 285, `fMin` at 295), which explicitly guards for the opposite-sign zero case before calling `std::min`/`std::max`. ### Trigger Conditions 1. A WebAssembly module contains `f32.min`, `f32.max`, `f64.min`, or `f64.max` where **both operands are compile-time constants** of opposite-sign zero 2. The function is called enough times to trigger BBQ tier-up (~1000 calls; no special flags required) 3. After tier-up, BBQ's constant-folded result — with the wrong sign — is returned on every subsequent call ## Version ### Reproduced Version - `main` branch latest commit (2026/04/19): `a4390137a403` - JavaScriptCore (WebKit trunk) ## Reproduction Case ### Release Build ```bash jsc poc.js ``` ``` f32.min(+0,-0): IPInt=-0 BBQ=+0 expected=-0 FAIL f32.max(-0,+0): IPInt=+0 BBQ=-0 expected=+0 FAIL f64.min(+0,-0): IPInt=-0 BBQ=+0 expected=-0 FAIL f64.max(-0,+0): IPInt=+0 BBQ=-0 expected=+0 FAIL ``` All four operations produce correct results in the IPInt interpreter and wrong results after BBQ tier-up, confirming the bug is isolated to BBQ constant folding. ### PoC Code (`poc.js`) ```js // JSC BBQ JIT signed zero constant folding bug // IPInt (interpreter) is correct; BBQ JIT constant folding is not. function uleb128(n) { const out = []; do { let b = n & 0x7f; n >>>= 7; if (n) b |= 0x80; out.push(b); } while (n); return out; } function sec(id, body) { return [id, ...uleb128(body.length), ...body]; } function str(s) { return [...uleb128(s.length), ...s.split('').map(c => c.charCodeAt(0))]; } function fn(code) { const b = [0x00, ...code, 0x0b]; return [...uleb128(b.length), ...b]; } const wasm = new Uint8Array([ 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, ...sec(0x01, [0x02, 0x60, 0x00, 0x01, 0x7d, // () -> f32 0x60, 0x00, 0x01, 0x7c, // () -> f64 ]), ...sec(0x03, [0x04, 0x00, 0x00, 0x01, 0x01]), ...sec(0x07, [0x04, ...str("f32_min"), 0x00, 0x00, // f32.min(+0, -0) ...str("f32_max"), 0x00, 0x01, // f32.max(-0, +0) ...str("f64_min"), 0x00, 0x02, // f64.min(+0, -0) ...str("f64_max"), 0x00, 0x03, // f64.max(-0, +0) ]), ...sec(0x0a, [0x04, ...fn([0x43,0x00,0x00,0x00,0x00, 0x43,0x00,0x00,0x00,0x80, 0x96]), // f32.min(+0, -0) ...fn([0x43,0x00,0x00,0x00,0x80, 0x43,0x00,0x00,0x00,0x00, 0x97]), // f32.max(-0, +0) ...fn([0x44,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, 0x44,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x80, 0xa4]), // f64.min(+0, -0) ...fn([0x44,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x80, 0x44,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, 0xa5]), // f64.max(-0, +0) ]), ]); const instance = new WebAssembly.Instance(new WebAssembly.Module(wasm)); const sign = x => (x === 0 && 1/x < 0) ? "-0" : "+0"; // Before BBQ tier-up: IPInt interpreter (correct) // Access instance.exports.X() directly to go through the full Wasm dispatch path const before = [ instance.exports.f32_min(), instance.exports.f32_max(), instance.exports.f64_min(), instance.exports.f64_max(), ]; // Trigger BBQ tier-up (~1000 calls via full dispatch path) for (let i = 0; i < 1000; i++) { instance.exports.f32_min(); instance.exports.f32_max(); instance.exports.f64_min(); instance.exports.f64_max(); } // After BBQ tier-up: constant folding (wrong) const after = [ instance.exports.f32_min(), instance.exports.f32_max(), instance.exports.f64_min(), instance.exports.f64_max(), ]; const expected = ["-0", "+0", "-0", "+0"]; const labels = ["f32.min(+0,-0)", "f32.max(-0,+0)", "f64.min(+0,-0)", "f64.max(-0,+0)"]; for (let i = 0; i < 4; i++) { const ok = sign(after[i]) === expected[i]; print(`${labels[i]}: IPInt=${sign(before[i])} BBQ=${sign(after[i])} expected=${expected[i]} ${ok ? "PASS" : "FAIL"}`); } ``` ## Suggested Patch ### File: `Source/JavaScriptCore/wasm/WasmBBQJIT.h` ```diff diff --git a/Source/JavaScriptCore/wasm/WasmBBQJIT.h b/Source/JavaScriptCore/wasm/WasmBBQJIT.h index 15a502d297b8..90ea157544e3 100644 --- a/Source/JavaScriptCore/wasm/WasmBBQJIT.h +++ b/Source/JavaScriptCore/wasm/WasmBBQJIT.h @@ -1684,6 +1684,16 @@ public: if (std::isnan(right)) return right; + // std::min/std::max return the first argument when equal (+0 == -0), so the sign + // of the result depends on argument order for opposite-sign zeros — handle explicitly. + if (left == static_cast<FloatType>(0.0) && right == static_cast<FloatType>(0.0) + && std::signbit(left) != std::signbit(right)) { + if constexpr (IsMinOrMax == MinOrMax::Min) + return static_cast<FloatType>(-0.0); + else + return static_cast<FloatType>(0.0); + } + if constexpr (IsMinOrMax == MinOrMax::Min) return std::min<FloatType>(left, right); else ``` The fix adds an explicit guard for the opposite-sign zero case before delegating to `std::min`/`std::max`, mirroring the existing correct handling in `MathCommon.h:285–302`. Confirmed: applying the patch makes all four PoC cases PASS; reverting restores FAIL. ### Credit Information Reporter credit: Junyoung Park (@candymate) of KAIST Hacking Lab
Attachments
poc.js
(2.62 KB, text/javascript)
2026-04-18 13:08 PDT
,
parkjuny
no flags
Details
View All
Add attachment
proposed patch, testcase, etc.
Radar WebKit Bug Importer
Comment 1
2026-04-19 13:22:12 PDT
<
rdar://problem/175122289
>
Keith Miller
Comment 2
2026-04-22 11:14:28 PDT
Pull request:
https://github.com/WebKit/WebKit/pull/63326
Keith Miller
Comment 3
2026-04-22 11:16:32 PDT
***
Bug 312994
has been marked as a duplicate of this bug. ***
EWS
Comment 4
2026-04-22 11:19:23 PDT
Committed
311790@main
(a03517078f7e): <
https://commits.webkit.org/311790@main
> Reviewed commits have been landed. Closing PR #63326 and removing active labels.
Note
You need to
log in
before you can comment on or make changes to this bug.
Top of Page
Format For Printing
XML
Clone This Bug