Skip to content

Wasm SIMD: i16x8.q15mulr_sat_s returns q15mulr(a, a) on x64 without AVX (Liftoff) — please cherry-pick V8 9d8bb1bf9a #67413

Open

Description

@thangnm93

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions