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:809–810 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
Go version
go1.26.5(toolchain hostdarwin/arm64, cross-compiling and executing onlinux/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 overk— the canonical shape forMulAddon packed panels:What did you expect to see?
Two instructions of arithmetic per k-step, plus the two B-panel loads:
Go's assembler already emits exactly that, including the EVEX embedded
broadcast, so this is not an ISA or an assembler limitation:
(Byte-identical.
go tool objdumpcannot decode EVEX, which is whyllvm-objdumpis 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):
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.goare213, plus theirloadandMaskedvariants.213hasresultInArg0: trueand writes its firstmultiplicand, so
acc = a.MulAdd(b, acc)can never land inacc's register: itneeds a live scratch register beyond the working set, always, and a copy to get the
value home. The
231shape is not foreign to the backend —VFMADD231SS/SDexist at
_gen/AMD64Ops.go:809–810and are whatmath.FMAuses — it just doesnot 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:213computesarg0*arg1 + arg2, so the folded operand isarg2— the addend.x.MulAdd(y, z)isx*y + z, so in an accumulating loopzis 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
231formexisted, its memory operand would have been a multiplicand, which is exactly what
a packed panel is.
3. Nothing in the SSA generators emits the
.BCSTsuffix.grep -rn BCST src/cmd/compile/internal/ssa/_gen/is empty, whilesrc/cmd/internal/obj/x86/(
evex.go,asm6.go) supports it. So the embedded broadcast — the piece thatremoves the
VMOVSS/VBROADCASTSSpair entirely — is reachable from hand-writtenassembly 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:
.BCST.BCST+ no statement-anchor NOPsThe 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".
MulAddis exactly the right source-level primitive and the source above is thenatural way to write the loop. It is a lowering gap, and each of the three parts
looks independently fixable — a
231op set with aloadvariant that folds amultiplicand, and a rule that recognizes a splat feeding an FMA and emits
.BCST.Related, and measured in the same loop:
BroadcastFloat32x16is an emulated op(
SetElem+Broadcast1To16) rather than aVBROADCASTSS, which is theVMOVSS+VBROADCASTSSpair above. Filed separately.cc #73787 #78979