Files
wasm2go/internal/gcasm/simdsplice_fuse_scalar_x64.go
f2b99fe737 gcasm: AVX2 8-bit dot selection for amd64 with runtime dispatch (#47)
* gcasm: AVX2 8-bit dot selection for amd64 with runtime dispatch

The amd64 SIMD splices lowered the quantized integer dot literally in
SSE — per 16-byte chunk, sign-extend each half of each operand to i16,
PMADDWD each half, PADDD the two: ~9 xmm ops. AVX2 does the whole chunk
in one 256-bit pass: VPMOVSXBW sign-extends all 16 bytes to i16 across a
ymm, VPMADDWD yields 8 i32 lanes (0..3 the low-half dot, 4..7 the high),
and VEXTRACTI128 + VPADDD fold the high 128 onto the low. int8*int8 fits
i16 and the pair sums fit i32, so nothing saturates and every lane is
bit-identical to the SSE lowering — five ops replace nine.

x64Dot8Rewrite mirrors the arm64 a64Dot8Rewrite: at splice time it
collapses a matched add(dot_low, dot_high) chunk into the synthetic
x64OpBlockDot reading the two byte sources directly, eliding the six
absorbed nodes. The x64 core emits the AVX2 sequence and one VZEROUPPER
per region on the exit path.

AVX2 is not the amd64 baseline (SSE4.1 / x86-64-v2 is), so it is gated
at runtime, not by build tag: the amd64 archSpec entry now carries the
"// avx2 dot" marker, so a body using it is emitted twice — an <fn>avx2
feature body and an <fn>sse portable twin (transformed with
PortableSIMD, keeping the literal SSE dot) — behind a NOSPLIT
tail-jump stub that branches on base.HasAVX2. cpufeat_amd64 detects
AVX2 with the standard CPUID/XGETBV probe (OSXSAVE + AVX + XCR0 YMM +
AVX2), dependency-free like the arm64 CPUDotProd detection.

Unit tests cover the rewrite (fires, portable suppresses, rejects a
non-single-use dot) and the emission (AVX2 mnemonics + VZEROUPPER vs
the literal SSE twin). Execution correctness is validated by the amd64
CI differential/loop suites and downstream by the llama q8 token-match
tests.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* gcasm: x64 fused-region memory addressing diet

Member loads inside fused regions and loops paid per-access Module
loads: every load re-fetched m.M (and every bounds check re-fetched
the memSize pointer and dereferenced it), then added the base by hand
— 11 instructions for one checked 16-byte load, ~60% of the q8
kernel's fused-loop body (profile: that kernel is 76% of amd64 tg).

Three changes, all x64-emitter-local:
- Loads finish with one indexed MOVOU (base)(R12*1) instead of the
  ADDQ base + plain MOVOU pair.
- x64HoistMem pins m.M and the memSize VALUE in free argument-tail
  registers once per fused region or loop (splices contain no calls,
  so memory cannot grow and both stay valid); member emitters then
  address through the pinned registers and bounds-check with a single
  CMPQ. Regions with one memory op keep the per-site loads.
- x64FusedEA64/x64FusedLaneAddr return the base register instead of
  fixing AX, so lane inserts ride the pinned base too.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* gcasm: amd64 fast-math 2x2 tile kernel (nrc2 row pairing)

The vec_dot row/column pairing (nrows=2) already restructures the
GEMM driver on every backend, but only arm64 had a native tile
kernel behind it (SMMLA, pp +26% measured); the amd64 feature body
kept the 4-subcall bit-exact companion, reloading every quant vector
twice. x64Nrc2Kernel is the AVX2 analog: per 34-byte block the four
quant vectors (rows x0/x1, cols y0/y1) load once and feed all four
dots — sign-extend to i16x16 halves, VPMADDWD the four pairs, and
collapse the four i32x8 sums into the 2x2 tile in one xmm with three
VPHADDD and a 128-fold. The f16 d scales gather via VPINSRW, convert
with VCVTPH2PS, spread with two VPERMILPS, and accumulate with
VFMADD231PS. Entirely VEX-encoded with VZEROUPPER on both exits (the
Intel dirty-upper hazard), bounds-checked like the arm64 kernel, both
pointer widths.

Like the arm64 kernel the retarget is fast-math-only (FMA regroups
the float accumulate; the integer tile is exact). HasAVX2 detection
now also requires FMA and F16C — every AVX2 CPU shipped has both, and
the tile kernel uses them.

Tests: kernel shape (loads once, 8 VPMADDWD, 6 bounds checks,
VZEROUPPER on every exit, ILP32 layout) and real-assembler
acceptance of the emitted body (GOOS=linux GOARCH=amd64 go build).

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* gcasm: VEX direct-destination forms for hot fused vector ops

The canned SSE paste stages every operand into X0/X1 and parks every
result back out with MOVDQU — the parking copies are the hottest
instructions in both decode and prompt profiles. Ops with a VEX
three-operand register form (add/sub/mul families, cvtdq2ps, maddwd)
now read their sources where they live and write the pool register
directly, eliminating both copies. VEX-128 zeroes the ymm uppers, so
no dirty-upper hazard; the portable SSE twin keeps the canned paste
(VEX is above its baseline).

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* gcasm: hoist strided window checks out of x64 fused loops

A pretest fused loop with a power-of-two step runs exactly
counter>>log2(Dec) iterations, so one loop-head check of
base + rlo + span + (iters-1)*delta against memSize covers every
iteration's window; the in-loop range-checked loads then collapse to
single indexed instructions (the memory64 folded form). Trapping at
the loop head instead of the precise access is the relaxation the
windowed range check already makes. Guards: memory64 trees, constant
strides, non-negative rlo, a zero-iteration skip, and MOVL
zero-extension of 32-bit counters.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* gcasm: fully-VEX fused regions with one deferred upper clear

Two wins over the previous emission, found by reading the hot loop:
the folded unchecked loads still carried their dead two-instruction
address computation (the fold decision ran after base
materialization — now it short-circuits at the top of the load
emitter), and every block dot paid a VZEROUPPER inside the loop.
The remaining legacy-SSE emissions on the fused path (vector copies,
the float pack, splats, load tails, the scalar chain's FP ops) now
emit their VEX forms, and when a rewritten tree's emission is
entirely VEX (x64TreeVexClean) the per-dot clears collapse into a
single VZEROUPPER at the region or loop exit — VEX-128 is safe under
dirty uppers, so the Intel hazard needs clearing only before
returning to gc-emitted legacy code.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

---------

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-08-20 16:32:40 +09:00

263 lines
7.1 KiB
Go

package gcasm
// Scalar-chain preamble for fused-region splices, amd64 — the twin of
// simdsplice_fuse_scalar_a64.go. Chains run in R12/DX (plus AX as the
// transient memory-base load, the same protocol as x64FusedLoad), f32
// values in the X0..X3 scratch, and each terminal is packed into its
// lane of X14 with INSERTPS, after the leaf float arguments. 32-bit
// MOVL/ADDL/SHLL forms give the u32 wrap and zero-extension the
// register-indexed addressing needs.
import (
"fmt"
"strings"
"github.com/goccy/wasm2go/internal/simdfuse"
)
type x64ScalarPre struct {
b *strings.Builder
tree *simdfuse.Tree
scalarReg func(simdfuse.Arg) string
offs *ModuleOffsets
uses []int
freeGpr []string
freeFpr []int
gprOf map[int]string
fprOf map[int]int
}
// newX64ScalarChain builds the inline scalar-chain evaluator; see the
// arm64 twin for the contract (scratch avoids X0, which may carry a
// chained v128 value mid-region).
func newX64ScalarChain(b *strings.Builder, tree *simdfuse.Tree,
scalarReg func(simdfuse.Arg) string, offs *ModuleOffsets, uses []int) *x64ScalarPre {
return &x64ScalarPre{
b: b, tree: tree, scalarReg: scalarReg, offs: offs, uses: uses,
freeGpr: []string{"DX", "R12"},
freeFpr: []int{3, 2, 1},
gprOf: map[int]string{},
fprOf: map[int]int{},
}
}
// emitNode evaluates one scalar node in place.
func (p *x64ScalarPre) emitNode(i int, n *simdfuse.Node) error {
return p.emit(i, n)
}
// takeAddrSource hands a chain-computed ADDRESS terminal to a memory
// emitter (GPR analog of takeSplatSource): the vector walk's
// placement loop already decremented the use, so this only frees the
// scratch slot on death. The emitters copy the value into R12 as
// their first instruction, so handing out a register the preamble
// later clobbers is safe.
func (p *x64ScalarPre) takeAddrSource(idx int) (string, error) {
g, ok := p.gprOf[idx]
if !ok {
return "", fmt.Errorf("fused splice %s: address source n%d not in a register", p.tree.Name, idx)
}
if p.uses[idx] == 0 {
delete(p.gprOf, idx)
p.freeGpr = append(p.freeGpr, g)
}
return g, nil
}
// takeSplatSource consumes a ClassF32 node feeding a splat, returning
// its register and freeing the scratch slot.
func (p *x64ScalarPre) takeSplatSource(idx int) (int, error) {
f, ok := p.fprOf[idx]
if !ok {
return 0, fmt.Errorf("fused splice %s: splat source n%d not in a register", p.tree.Name, idx)
}
// The vector walk's placement loop already decremented this use;
// free the scratch slot on death.
if p.uses[idx] == 0 {
delete(p.fprOf, idx)
p.freeFpr = append(p.freeFpr, f)
}
return f, nil
}
func (p *x64ScalarPre) allocGpr() (string, error) {
if len(p.freeGpr) == 0 {
return "", fmt.Errorf("fused splice %s: %w: scalar GPR scratch exhausted", p.tree.Name, errFusedCapacity)
}
r := p.freeGpr[len(p.freeGpr)-1]
p.freeGpr = p.freeGpr[:len(p.freeGpr)-1]
return r, nil
}
func (p *x64ScalarPre) allocFpr() (int, error) {
if len(p.freeFpr) == 0 {
return 0, fmt.Errorf("fused splice %s: %w: scalar FPR scratch exhausted", p.tree.Name, errFusedCapacity)
}
f := p.freeFpr[len(p.freeFpr)-1]
p.freeFpr = p.freeFpr[:len(p.freeFpr)-1]
return f, nil
}
func (p *x64ScalarPre) takeGpr(a simdfuse.Arg) (string, error) {
switch a.Kind {
case simdfuse.ArgNode:
src, ok := p.gprOf[a.Index]
if !ok {
return "", fmt.Errorf("fused splice %s: scalar operand n%d not in a register", p.tree.Name, a.Index)
}
p.uses[a.Index]--
if p.uses[a.Index] == 0 {
delete(p.gprOf, a.Index)
return src, nil
}
r, err := p.allocGpr()
if err != nil {
return "", err
}
if p.tree.Addr64 {
fmt.Fprintf(p.b, "\tMOVQ %s, %s\n", src, r)
} else {
fmt.Fprintf(p.b, "\tMOVL %s, %s\n", src, r)
}
return r, nil
case simdfuse.ArgScalar:
r, err := p.allocGpr()
if err != nil {
return "", err
}
if p.tree.Addr64 {
fmt.Fprintf(p.b, "\tMOVQ %s, %s\n", p.scalarReg(a), r)
} else {
fmt.Fprintf(p.b, "\tMOVL %s, %s\n", p.scalarReg(a), r)
}
return r, nil
case simdfuse.ArgSum:
r, err := p.allocGpr()
if err != nil {
return "", err
}
if p.tree.Addr64 {
fmt.Fprintf(p.b, "\tMOVQ %s, %s\n", p.scalarReg(a), r)
if a.Const != 0 {
fmt.Fprintf(p.b, "\tADDQ $%d, %s\n", int64(a.Const), r)
}
} else {
fmt.Fprintf(p.b, "\tMOVL %s, %s\n", p.scalarReg(a), r)
if a.Const != 0 {
fmt.Fprintf(p.b, "\tADDL $%d, %s\n", a.Const, r)
}
}
return r, nil
case simdfuse.ArgConst:
r, err := p.allocGpr()
if err != nil {
return "", err
}
if p.tree.Addr64 {
fmt.Fprintf(p.b, "\tMOVQ $%d, %s\n", int64(a.Const), r)
} else {
fmt.Fprintf(p.b, "\tMOVL $%d, %s\n", a.Const, r)
}
return r, nil
}
return "", fmt.Errorf("fused splice %s: bad scalar operand kind", p.tree.Name)
}
func (p *x64ScalarPre) takeFpr(a simdfuse.Arg) (int, error) {
if a.Kind != simdfuse.ArgNode {
return 0, fmt.Errorf("fused splice %s: f32 operand must be a node", p.tree.Name)
}
src, ok := p.fprOf[a.Index]
if !ok {
return 0, fmt.Errorf("fused splice %s: f32 operand n%d not in a register", p.tree.Name, a.Index)
}
p.uses[a.Index]--
if p.uses[a.Index] == 0 {
delete(p.fprOf, a.Index)
return src, nil
}
f, err := p.allocFpr()
if err != nil {
return 0, err
}
fmt.Fprintf(p.b, "\tVMOVAPS X%d, X%d\n", src, f)
return f, nil
}
func (p *x64ScalarPre) emit(i int, n *simdfuse.Node) error {
switch n.Op {
case "scalar_i32_load16_u":
g, err := p.takeGpr(n.Args[0])
if err != nil {
return err
}
fmt.Fprintf(p.b, "\tMOVWLZX (%s)(%s*1), %s\n", x64MemBase(p.b, p.offs), g, g)
p.gprOf[i] = g
case "scalar_i32_shl":
g, err := p.takeGpr(n.Args[0])
if err != nil {
return err
}
if p.tree.Addr64 {
fmt.Fprintf(p.b, "\tSHLQ $%d, %s\n", uint64(n.Args[1].Const)%64, g)
} else {
fmt.Fprintf(p.b, "\tSHLL $%d, %s\n", uint32(n.Args[1].Const)%32, g)
}
p.gprOf[i] = g
case "scalar_i32_add":
g, err := p.takeGpr(n.Args[0])
if err != nil {
return err
}
addOp := "ADDL"
imm := int64(n.Args[1].Const)
if p.tree.Addr64 {
addOp = "ADDQ"
}
r := n.Args[1]
switch r.Kind {
case simdfuse.ArgConst:
fmt.Fprintf(p.b, "\t%s $%d, %s\n", addOp, imm, g)
case simdfuse.ArgScalar:
fmt.Fprintf(p.b, "\t%s %s, %s\n", addOp, p.scalarReg(r), g)
case simdfuse.ArgNode:
g2, err := p.takeGpr(r)
if err != nil {
return err
}
fmt.Fprintf(p.b, "\t%s %s, %s\n", addOp, g2, g)
p.freeGpr = append(p.freeGpr, g2)
default:
return fmt.Errorf("fused splice %s: bad scalar_i32_add operand", p.tree.Name)
}
p.gprOf[i] = g
case "scalar_f32_load":
g, err := p.takeGpr(n.Args[0])
if err != nil {
return err
}
f, err := p.allocFpr()
if err != nil {
return err
}
fmt.Fprintf(p.b, "\tVMOVSS (%s)(%s*1), X%d\n", x64MemBase(p.b, p.offs), g, f)
p.freeGpr = append(p.freeGpr, g)
p.fprOf[i] = f
case "scalar_f32_mul":
fl, err := p.takeFpr(n.Args[0])
if err != nil {
return err
}
fr, err := p.takeFpr(n.Args[1])
if err != nil {
return err
}
fmt.Fprintf(p.b, "\tVMULSS X%d, X%d, X%d\n", fr, fl, fl)
p.freeFpr = append(p.freeFpr, fr)
p.fprOf[i] = fl
default:
return fmt.Errorf("fused splice %s: unknown scalar op %q", p.tree.Name, n.Op)
}
return nil
}