Skip to content

cmd/compile: archsimd MulAdd cannot accumulate in place or fold a broadcast memory operand (no VFMADD231PS, no .BCST), though the assembler encodes both #80829

Description

@scttfrdmn

Go version

go1.26.5 (toolchain host darwin/arm64, cross-compiling and executing on linux/amd64), GOEXPERIMENT=simd.

What did you do?

Wrote the inner loop of an SGEMM microkernel — one row of a 1×32 tile, i.e.
c[0:32] += a[k] * b[k][0:32] accumulated over k — the canonical shape for
MulAdd on packed panels:

package main

import "simd/archsimd"

//go:noinline
func row(a, b, c []float32, kc int) {
	c0 := archsimd.LoadFloat32x16Slice(c[0:16])
	c1 := archsimd.LoadFloat32x16Slice(c[16:32])
	ap, bp := a[:kc], b[:kc*32]
	for len(ap) >= 1 && len(bp) >= 32 {
		av := archsimd.BroadcastFloat32x16(ap[0])
		c0 = av.MulAdd(archsimd.LoadFloat32x16Slice(bp[0:16]), c0)
		c1 = av.MulAdd(archsimd.LoadFloat32x16Slice(bp[16:32]), c1)
		ap, bp = ap[1:], bp[32:]
	}
	c0.StoreSlice(c[0:16])
	c1.StoreSlice(c[16:32])
}

What did you expect to see?

Two instructions of arithmetic per k-step, plus the two B-panel loads:

VMOVDQU64        (DI), Z3
VMOVDQU64      64(DI), Z4
VFMADD231PS.BCST (AX), Z3, Z0     // Z0 += Z3 * broadcast(float32 at (AX))
VFMADD231PS.BCST (AX), Z4, Z1

Go's assembler already emits exactly that, including the EVEX embedded
broadcast, so this is not an ISA or an assembler limitation:

$ cat x_amd64.s
TEXT ·probe(SB), NOSPLIT, $0-0
	VFMADD231PS.BCST 12(SI), Z1, Z0
	RET

$ GOARCH=amd64 GOOS=linux go build -o probe . && llvm-objdump -d probe | grep -A1 probe.abi0
000000000047a7c0 <main.probe.abi0>:
  47a7c0: 62 f2 75 58 b8 46 03  vfmadd231ps 0xc(%rsi){1to16}, %zmm1, %zmm0 # zmm0 = (zmm1 * mem) + zmm0

$ echo 'vfmadd231ps 12(%rsi){1to16}, %zmm1, %zmm0' | llvm-mc -triple=x86_64 --show-encoding
	vfmadd231ps 12(%rsi){1to16}, %zmm1, %zmm0 # encoding: [0x62,0xf2,0x75,0x58,0xb8,0x46,0x03]

(Byte-identical. go tool objdump cannot decode EVEX, which is why llvm-objdump
is used above.)

What did you see instead?

Nine instructions of arithmetic path per k-step for two FMAs (full steady-state
loop is 27 instructions; the rest is loop control and bounds-check-free panel
re-slicing):

00088 (main.go:14)  VMOVSS        (AX), X2      // a[k]
00129 (broadcast)   VBROADCASTSS  X2, Z2        // ... in two instructions
00135 (load)        VMOVDQU64     (DI), Z3
00141 (load)        VMOVDQU64   64(DI), Z4
00148 (main.go:15)  VMOVDQU64     Z2, Z5        // copy: 213 clobbers arg0
00154 (main.go:15)  VFMADD213PS   Z0, Z3, Z2    // Z2 = Z2*Z3 + Z0
00160 (main.go:16)  VFMADD213PS   Z1, Z4, Z5
00179 (main.go:13)  VMOVDQU64     Z5, Z1        // move results back to the
00185 (main.go:13)  VMOVDQU64     Z2, Z0        //   accumulators' registers

Three independent things block the expected form, which is why I think this is one
report rather than three:

1. There is no 231- or 132-shaped FMA SSA op for vector types. All 20
VFMADD* entries in _gen/simdAMD64ops.go are 213, plus their load and
Masked variants. 213 has resultInArg0: true and writes its first
multiplicand, so acc = a.MulAdd(b, acc) can never land in acc's register: it
needs a live scratch register beyond the working set, always, and a copy to get the
value home. The 231 shape is not foreign to the backend — VFMADD231SS/SD
exist at _gen/AMD64Ops.go:809810 and are what math.FMA uses — it just does
not exist for vectors.

2. The one load-merging rule that exists can only fold the addend, which in a
GEMM is the accumulator.
From _gen/simdAMD64.rules:2774:

(VFMADD213PS512 x y l:(VMOVDQUload512 {sym} [off] ptr mem)) && canMergeLoad(v, l) && clobber(l)
    => (VFMADD213PS512load {sym} [off] x y ptr mem)

213 computes arg0*arg1 + arg2, so the folded operand is arg2 — the addend.
x.MulAdd(y, z) is x*y + z, so in an accumulating loop z is the accumulator,
and folding it means reading the accumulator from memory every iteration. That is
the one operand a GEMM kernel must keep in a register, so the rule cannot fire
usefully here even though it is the right rule for other shapes. Had a 231 form
existed, its memory operand would have been a multiplicand, which is exactly what
a packed panel is.

3. Nothing in the SSA generators emits the .BCST suffix. grep -rn BCST src/cmd/compile/internal/ssa/_gen/ is empty, while src/cmd/internal/obj/x86/
(evex.go, asm6.go) supports it. So the embedded broadcast — the piece that
removes the VMOVSS/VBROADCASTSS pair entirely — is reachable from hand-written
assembly and from nothing else.

Why it matters

Measured on the shipped 2×32 microkernel of a pure-Go float32 BLAS subset
(https://github.com/scttfrdmn/keel), steady-state loop, 16 FMAs per pass:

body insns insns/FMA fraction of measured peak on an i9-9960X
as emitted today 74 4.625 46.0% (measured)
with a 231 form (folds the copies) 66 4.125 51.7% (projected)
with 231 + .BCST 50 3.125 68.2% (projected)
with 231 + .BCST + no statement-anchor NOPs 46 2.875 74.2% (projected)

The denominator is that host's own measured single-core register-only FMA
saturation rate, taken in the same benchmark invocation, not a formula. On that
part the kernel is at its front end's limit, not its FMA units': the two shipped
tile shapes' throughputs stand in the inverse ratio of their instruction counts
(1.308 measured against 1.351 predicted) and both derive the same ~4.2
instructions per cycle despite differing 35% in instruction count. So on a
2-FMA/cycle, ~4-wide-issue machine, instruction count is the performance, and 32
of these 74 instructions are the four groups above.

Two Zen hosts with wider front ends (7950X3D, Ryzen AI MAX+ 395) reach 96.6% and
64.2% of their measured peaks with the same source, so this is not a
whole-fleet regression — it is a specific, quantified gap between what the
assembler can encode and what the intrinsics can reach, and it binds hardest on
exactly the parts with two full-width 512-bit FMA units.

Note that the 231 form alone is not sufficient: folding the accumulate but not
the broadcast leaves 66 instructions and is projected at 51.7%, still short. The
embedded broadcast is the larger of the two levers.

To be clear about scope: this is not "archsimd is missing an operation".
MulAdd is exactly the right source-level primitive and the source above is the
natural way to write the loop. It is a lowering gap, and each of the three parts
looks independently fixable — a 231 op set with a load variant that folds a
multiplicand, and a rule that recognizes a splat feeding an FMA and emits .BCST.

Related, and measured in the same loop: BroadcastFloat32x16 is an emulated op
(SetElem + Broadcast1To16) rather than a VBROADCASTSS, which is the
VMOVSS+VBROADCASTSS pair above. Filed separately.

cc #73787 #78979

Activity

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

Metadata

Metadata

Labels

Type

No type

Projects

Relationships

None yet

Development

No branches or pull requests

Issue actions