Repository navigation
Description
Version
v22.23.4 (V8 12.4.254.21-node.57); also v24.22.2 (V8 13.6.233.17-node.63) and v25.9.0 (V8 14.1.146.11-node.25)
Platform
Linux x86_64 on a CPU without AVX (cpuinfo has sse4_2 and fma, no avx / avx2). Reproduced in a linux/amd64 container
under Rosetta on Apple Silicon and on a QEMU VM ("QEMU Virtual CPU version 2.5+"). arm64 is not affected.
Subsystem
v8 (WebAssembly SIMD, Liftoff x64)
What steps will reproduce the bug?
Save as repro.mjs and run node --liftoff --no-wasm-tier-up repro.mjs on an x64 machine without AVX.
The flags only keep the function on Liftoff; with the default flags the first calls of a function run on Liftoff too, so
the wrong result shows up without any flag until the function tiers up.
// i16x8.q15mulr_sat_s(a, b) where `a` is a local that stays live: on x64 without AVX, Liftoff returns q15mulr(a, a).
const leb = (n) => { const o = []; do { let b = n & 0x7f; n >>>= 7; if (n) b |= 0x80; o.push(b) } while (n); return o };
const section = (id, body) => [id, ...leb(body.length), ...body];
const load = (addr) => [0x41, ...leb(addr), 0xfd, 0x00, 0x04, 0x00]; // i32.const addr; v128.load
const store = [0xfd, 0x0b, 0x04, 0x00]; // v128.store
const q15mulr = [0xfd, 0x82, 0x01]; // i16x8.q15mulr_sat_s
// (func (local $a v128)
// (local.set $a (v128.load (i32.const 0)))
// (v128.store (i32.const 32) (i16x8.q15mulr_sat_s (local.get $a) (v128.load (i32.const 16))))
// (v128.store (i32.const 48) (local.get $a))) ;; keeps $a live after the multiply
const body = [1, 1, 0x7b, // one v128 local
...load(0), 0x21, 0,
0x41, 32, 0x20, 0, ...load(16), ...q15mulr, ...store,
0x41, 48, 0x20, 0, ...store, 0x0b];
const bytes = new Uint8Array([0x00, 0x61, 0x73, 0x6d, 1, 0, 0, 0,
...section(1, [1, 0x60, 0, 0]), ...section(3, [1, 0]), ...section(5, [1, 0, 1]),
...section(7, [2, 1, 0x66, 0, 0, 1, 0x6d, 2, 0]), ...section(10, [1, ...leb(body.length), ...body])]);
const { f, m } = new WebAssembly.Instance(new WebAssembly.Module(bytes)).exports;
const v = new Int16Array(m.buffer);
v.set([100, 200, 300, 400, 500, 600, 700, 800], 0); // a
v.set([0, 0, 0, 0, 0, 0, 0, 0], 8); // b
f();
const spec = (x, y) => Math.min(32767, (x * y + 0x4000) >> 15);
console.log(`node ${process.version} (V8 ${process.versions.v8}) ${process.arch}`);
console.log('expected', [...v.subarray(0, 8)].map((x, i) => spec(x, v[8 + i])).join(','));
console.log('actual ', [...v.subarray(16, 24)].join(','));How often does it reproduce? Is there a required condition?
Always, on x64 without AVX, while the function runs on Liftoff. Required: the first operand of i16x8.q15mulr_sat_s is
still live after the instruction (here a local), so Liftoff gives the result the second operand's register. With fresh
operands, under TurboFan (--no-liftoff), or on arm64 the result is right.
We hit it in production code: a bilinear image-scaling kernel keeps its interpolation weights in a local across several
q15mulr lerps, and the first renders of each worker came out with a grid of half-transparent lines on a host without AVX.
What is the expected behavior? Why is that the expected behavior?
expected 0,0,0,0,0,0,0,0 — i16x8.q15mulr_sat_s(a, 0) is 0 in every lane (spec: sat_s((a * b + 0x4000) >> 15)).
arm64 prints:
node v22.23.4 (V8 12.4.254.21-node.57) arm64
expected 0,0,0,0,0,0,0,0
actual 0,0,0,0,0,0,0,0
What do you see instead?
The lanes are q15mulr(a, a) (100² → 0, 200² → 1, 300² → 3, …), the same on 22, 24 and 25:
node v22.23.4 (V8 12.4.254.21-node.57) x64
expected 0,0,0,0,0,0,0,0
actual 0,1,3,5,8,11,15,20
Liftoff code from --print-wasm-code (Node 22): a is in xmm0, b is loaded into xmm1, then
movups xmm1,[rax+0x10] ; b
pcmpeqd xmm15,xmm15
psllw xmm15,15
movaps xmm1,xmm0 ; dst (= b's register) := a — b is lost
pmulhrsw xmm1,xmm1 ; a * a
pcmpeqw xmm15,xmm1
xorps xmm1,xmm15
Additional information
This is fixed upstream by v8/v8@9d8bb1b
("[wasm][x64][ia32] Fix no-AVX Liftoff q15mulr when destination aliases rhs", https://issues.chromium.org/issues/547572465,
V8 15.4): it swaps the operands when dst == src2 and moves emit_i16x8_relaxed_q15mulr_s to EmitSimdCommutativeBinOp.
Could it be cherry-picked to v24.x and v22.x (and the current release line)?
On an x64 CPU that has AVX, --no-enable-avx should make V8 take the same SSE code path (we have only tested on CPUs
without AVX).
Activity