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.
Go version
go version go1.26.5 linux/amd64(also reproduced cross-compiling from darwin/arm64)Output of
go envin your module/workspaceWhat did you do?
Compiled functions using
simd/archsimdvector operations:What did you see happen?
The scalar shift count is materialized into an XMM register with a legacy-encoded (non-VEX)
MOVQin the middle of VEX code: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 legacyMOVQ. Replacing the scalar-count shift with a per-laneShiftRightfed 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
MOVUPSstores of the X15 zero register even inside functions full of EVEX instructions:compiles to:
(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
VZEROUPPERat the boundaries. GCC and Clang emitvmovq/vmovupsinside 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:
and if the register-count form is kept, the move should at minimum be VEX-encoded:
For the second manifestation, the zeroing stores should use the VEX encoding of the same instruction:
Adopting VEX encodings would also allow wider zeroing stores: 32 bytes per
VMOVDQUfrom a YMM register, or 64 bytes perVMOVDQU64from 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 VEXVPXOR, 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.