Skip to content

cmd/compile: legacy SSE encodings emitted in functions using simd/archsimd intrinsics cause AVX-SSE transition penalties #80835

Description

@achille-roussel

Go version

go version go1.26.5 linux/amd64 (also reproduced cross-compiling from darwin/arm64)

Output of go env in your module/workspace

GOARCH=amd64
GOOS=linux
GOEXPERIMENT=simd
GOAMD64=v1 (also reproduces with v3/v4)

What did you do?

Compiled functions using simd/archsimd vector operations:

package repro

import "simd/archsimd"

func ShiftImm(x archsimd.Uint32x8) archsimd.Uint32x8 {
	return x.ShiftAllRight(27)
}

What did you see happen?

The scalar shift count is materialized into an XMM register with a legacy-encoded (non-VEX) MOVQ in the middle of VEX code:

MOVL $0x1b, AX
MOVQ AX, X1        // 66 48 0f 6e c8   <- legacy SSE encoding, no VEX prefix
VPSRLD X1, Y0, Y0  // c5 fd d2 c1      <- VEX

Executing a legacy SSE instruction while the upper halves of the YMM/ZMM registers are dirty triggers an AVX-SSE state transition penalty on Intel CPUs (Intel 64 and IA-32 Architectures Optimization Reference Manual, "Mixing AVX Code with SSE Code"). Since surrounding archsimd code generally leaves the uppers dirty, every call through such a function pays the penalty.

Measured impact (Intel Xeon Platinum 8581C, Emerald Rapids): a parquet bloom filter block insert using ShiftAllRight(27) between 256-bit operations ran at 132ns/op instead of 2ns/op — about 65x slower — with ~96% of profile samples on the two instructions following the legacy MOVQ. Replacing the scalar-count shift with a per-lane ShiftRight fed by a constant vector loaded from memory restored the expected performance.

A second manifestation is the compiler's stack zeroing idiom, which clears zeroed stack memory with legacy-encoded MOVUPS stores of the X15 zero register even inside functions full of EVEX instructions:

func ReduceStore(x archsimd.Uint64x8) uint64 {
	var t [8]uint64
	acc := archsimd.LoadUint64x8Slice(t[:])
	acc = acc.Add(x)
	acc.StoreSlice(t[:])
	var n uint64
	for _, v := range t {
		n += v
	}
	return n
}

compiles to:

MOVUPS X15, (CX)       // 44 0f 11 39     <- legacy SSE zeroing stores
MOVUPS X15, 0x10(CX)   // 44 0f 11 79 10
MOVUPS X15, 0x20(CX)
MOVUPS X15, 0x30(CX)
VPADDQ (SP), Z0, Z0    // 62 f1 fd 48 d4  <- EVEX
VMOVDQU64 Z0, (SP)     // 62 f1 fe 48 7f

(the vector load/store intrinsics themselves are correctly EVEX-encoded; only the zeroing is legacy). In the code where we found this — a vector accumulator reduced through a stack array once per call — the transition penalty cost ~155ns per call, and in CPU profiles the time is attributed to the first EVEX instruction of the next call, which makes the root cause hard to find. Rewriting the reduction with GetHi/GetLo/GetElem (register-only, no stack array to zero) removed it.

Both cases were found while porting hand-written assembly kernels in parquet-go to archsimd (parquet-go/parquet-go#584; analysis notes in the repository under docs/archsimd-port.md).

What did you expect to see?

Functions containing VEX/EVEX instructions should use VEX encodings for all compiler-generated XMM/YMM traffic (scalar-to-vector moves, spills, stack copies, zeroing), or the compiler should manage the transition with VZEROUPPER at the boundaries. GCC and Clang emit vmovq/vmovups inside AVX-enabled functions for this reason.

For the first repro, the ideal lowering uses the immediate form of the shift, which needs no scalar materialization at all:

VPSRLD $0x1b, Y0, Y0   // c5 fd 72 d0 1b — immediate form, single instruction

and if the register-count form is kept, the move should at minimum be VEX-encoded:

MOVL $0x1b, AX
VMOVQ AX, X1           // c4 e1 f9 6e c8 — VEX encoding of the same move
VPSRLD X1, Y0, Y0      // c5 fd d2 c1

For the second manifestation, the zeroing stores should use the VEX encoding of the same instruction:

VMOVUPS X15, (CX)       // c5 78 11 39 — VEX encoding of the same store
VMOVUPS X15, 0x10(CX)
VMOVUPS X15, 0x20(CX)
VMOVUPS X15, 0x30(CX)

Adopting VEX encodings would also allow wider zeroing stores: 32 bytes per VMOVDQU from a YMM register, or 64 bytes per VMOVDQU64 from a ZMM register, halving or quartering the number of stores in functions that already use those register widths (provided the zero register's upper bits are known zero, e.g. by materializing it with a VEX VPXOR, which zeroes the full width).

Since the fix could take several forms (per-instruction lowering fixes, promoting legacy encodings to VEX at the assembler level when a function contains AVX instructions, or VZEROUPPER insertion), filing this as an issue rather than a CL to discuss the preferred approach.

Activity

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

Metadata

Metadata

Assignees

Labels

FixPendingIssues that have a fix which has not yet been reviewed or submitted.compiler/runtimeIssues related to the Go compiler and/or runtime.

Type

No type

Projects

Relationships

None yet

Development

No branches or pull requests

Issue actions