Bug 312689

Summary: BBQ JIT Constant Folding Signed Zero Mishandling in `computeFloatingPointMinOrMax`
Product: WebKit Reporter: parkjuny
Component: WebAssemblyAssignee: Keith Miller <keith_miller>
Status: RESOLVED FIXED    
Severity: Minor CC: keith_miller, webkit-bug-importer
Priority: P2 Keywords: InRadar
Version: WebKit Nightly Build   
Hardware: PC   
OS: Linux   
Attachments:
Description Flags
poc.js none

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
Radar WebKit Bug Importer
Comment 1 2026-04-19 13:22:12 PDT
Keith Miller
Comment 2 2026-04-22 11:14:28 PDT
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.