Platformonafhankelijke SIMD in Go
Go 1.26 en 1.27 bevatten experimentele API's voor SIMD-operaties (Single Instruction Multiple Data). SIMD is een native functie van veel moderne CPU's waarmee software zeer snel uniforme operaties kan uitvoeren over vectoren van data, zoals het optellen van 8 paren float64-waarden in een enkele instructie. Dit kan veel rekenintensieve taken aanzienlijk versnellen, variërend van cryptografie en dataverwerking tot AI. Sterker nog, de Green Tea garbage collector van Go maakt al gebruik van SIMD om het scannen van het geheugen naar actieve objecten te versnellen.
Vóór deze nieuwe experimentele API's was de enige manier om deze functionaliteit vanuit Go te benutten het schrijven van Go-assembly. Dit was alleen de moeite waard voor echt prestatie-kritische compute kernels, wat betekende dat veel software die baat zou hebben gehad bij SIMD, simpelweg een groot deel van de CPU onbenut liet.
Go 1.26 introduceerde een SIMD-API voor amd64, en Go 1.27 voegde API's toe voor arm64 (specifiek NEON) en wasm. Een fundamentele uitdaging voor een SIMD-API is echter de enorme variatie tussen platforms, niet alleen in welke operaties ze ondersteunen, maar zelfs in hoe vectoren worden gerepresenteerd. Sommige platforms bieden vectoren van vaste grootte, typisch tussen de 128 en 512 bits, terwijl bij andere de vectorgrootte bij build-tijd niet bekend is en moet worden opgevraagd wanneer het programma start. Om volledige toegang te bieden tot de breedte van deze platforms, bevinden deze API's zich in een architectuurafhankelijk archsimd-pakket.
Go 1.27 gaat echter verder dan deze architectuurafhankelijke API's en introduceert een experimentele, volledig draagbare, platform- en grootte-agnostische SIMD-interface, losjes gebaseerd op Highway voor C++. Het doel is om "write-once" code met prestaties die dicht bij assembly liggen te ondersteunen op platforms met SIMD-ondersteuning, en om een competente emulatie te bieden op platforms die (nog) geen SIMD-ondersteuning hebben. Het simd-pakket ondersteunt momenteel AVX, AVX2 en AVX512 op amd64, NEON op arm64, en de SIMD-instructies van wasm.
Motivatie: variatie tussen SIMD-architecturen
SIMD-architecturen variëren op verschillende vlakken:
- Vectorgrootte: Sommige bieden één vaste vectorgrootte (
wasm,PowerPCens390x, 128 bits). Andere bieden meerdere vaste vectorgroottes (amd64met 128, 256 en 512;loong64met 128 en 256).Riscv64ondersteunt vectoren van ongespecificeerde grootte tussen 128 en 65536 bits, hoewel de lengte beperkt is tot machten van 2.Arm64ondersteunt één vaste grootte (128 bits, NEON) en één variabele grootte (128-2048 bits, alleen machten van twee, SVE). - Feature-checks: Op een specifieke instantie van een architectuur is het nodig om te controleren welke groottes worden ondersteund: bij
amd64is dat AVX, AVX2 of AVX512? Bijarm64is dat NEON of SVE? En indien SVE: hoe groot is deze en welke variant (SVE, SVE2 of SVE2.1)? - Vector Masking: If-then-else constructies over een vector kunnen worden geïmplementeerd met maskers. Sommige SIMD-varianten bieden geen maskers; alle operaties werken over alle elementen en "masking" gebeurt met vector-bitmasks en booleaanse operaties (
wasm,AVX,AVX2,NEON). Andere bieden speciale maskregisters (AVX512enRVV). SVE wijst één bit toe per vectorbyte, waarbij het minst significante bit van de maskbits van elk element de gemaskeerde operaties aanstuurt. AVX2 ondersteunt ook gemaskeerde loads en stores, maar gebruikt een gewone vector als masker, waarbij het meest significante bit de operatie aanstuurt. - Operaties: Elke architectuur biedt eigen primitieven voor het herordenen van vectorelementen. Verschillende architecturen ondersteunen verschillende crypto-gerelateerde operaties. Zelfs basisrekenkunde kan variëren;
wasmmist bijvoorbeeld vergelijkingen voor vectoren van 64-bit integers.
Hoewel het architectuurafhankelijke archsimd-pakket van Go is ontworpen om zo uniform mogelijk te zijn, blijven veel van deze eigenaardigheden bestaan, wat het ontwerpen, schrijven en testen van code voor multiplatform SIMD zwaar maakt.
Overzicht
Het nieuwe simd-pakket verbergt deze verschillen door vectoren van vaste grootte uit het typesysteem te verwijderen. Het ondersteunt alleen die operaties die in de doorsnede van alle verschillende platforms liggen, en vult gaten in die doorsnede op met efficiënte emulatie via andere SIMD-instructies.
Het doel is een set operaties die:
- Voldoende is om veel dataverwerkingsalgoritmen te ondersteunen die baat hebben bij een gevectoriseerde implementatie (maar niet gebonden zijn aan een specifieke vectorgrootte).
- Net zo efficiënt is als assembly wanneer de broncode-operaties overeenkomen met de onderliggende hardware.
- Anders zo goed mogelijk wordt geëmuleerd.
- Eenvoudig te lezen en te begrijpen is (zeker als een LLM de code schrijft).
Op platforms die SIMD-instructies missen of die geen ondersteuning hebben in archsimd, worden alle operaties geëmuleerd, zodat code geschreven met het simd-pakket altijd zal draaien.
Om dit experimentele pakket te gebruiken, stel je GOEXPERIMENT=simd in tijdens het build-proces.
De SIMD-vectortypen zijn simpelweg hoofdletter-meervoud-primitieven, bijvoorbeeld simd.Uint8s of simd.Float32s. Vectoren worden geladen vanuit en opgeslagen in slices.
Voorbeeld: Inner Product
// innerProduct berekent het inwendig product van x en y.
func innerProduct(x, y []float32) float32 {
var a simd.Float32s
var i int
for i = 0; i < len(x)-a.Len()+1; i += a.Len() {
u := simd.LoadFloat32s(x[i : i+a.Len()])
v := simd.LoadFloat32s(y[i : i+a.Len()])
a = u.MulAdd(v, a)
}
if i < len(x) {
u, _ := simd.LoadFloat32sPart(x[i:])
v, _ := simd.LoadFloat32sPart(y[i:])
a = u.MulAdd(v, a)
}
return sum(a)
}
// sum berekent de scalaire som van de elementen van x.
func sum(x simd.Float32s) float32 {
s := make([]float32, x.Len())
x.Store(s)
var r float32
for _, e := range s {
r += e
}
return r
}
Let op: Dit voorbeeld toont een beperking van de eerste experimentele release; omdat er geen gemeenschappelijke manier is om over alle elementen van een vector te sommeren, wordt dit in Go 1.27 niet ondersteund door simd. ReduceSum zal in de volgende release verschijnen, waardoor sum vervangen kan worden door simd.ReduceSum.
SIMD-vergelijkingen produceren maskwaarden die specifiek zijn voor de bijbehorende vectorbreedte (bijv. vergelijkingen van Int8s produceren Mask8s). Deze maskwaarden kunnen worden gebruikt om vectoren te selecteren en te filteren.
Ondersteunde simd package-operaties in Go 1.27
In onderstaande tabellen staan: V en U voor vectortypen, M voor een masktype, E voor een scalair type en W voor een breedte.
Package-Level Load / Broadcast Functies
| Functie | Int8s | Int16s | Int32s | Int64s | Uint8s | Uint16s | Uint32s | Uint64s | Float32s | Float64s |
|---|---|---|---|---|---|---|---|---|---|---|
LoadV([]E) V | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y |
LoadVPart([]E) (V, int) | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y |
BroadcastV(E) V | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y |
Store/String operaties (x V).Method(...)
| Methode | Int8s | Int16s | Int32s | Int64s | Uint8s | Uint16s | Uint32s | Uint64s | Float32s | Float64s |
|---|---|---|---|---|---|---|---|---|---|---|
Store(s []E) | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y |
StorePart(s []E) int | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y |
String() string | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y |
Rekenkundige operaties (x V).Method(...) V
| Methode | Int8s | Int16s | Int32s | Int64s | Uint8s | Uint16s | Uint32s | Uint64s | Float32s | Float64s |
|---|---|---|---|---|---|---|---|---|---|---|
Abs() V | Y | Y | ||||||||
Add(y V) V | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y |
AddSaturated(y V) V | Y | Y | Y | Y | ||||||
Average(y V) V | Y | Y | Y | Y | ||||||
Div(y V) V | Y | Y | ||||||||
IfElse(mask MaskWs, y V) V | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y |
Len() int | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y |
Masked(mask MaskWs) V | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y |
Max(y V) V | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y |
Min(y V) V | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y |
Mul(y V) V | Y | Y | ||||||||
MulAdd(y V, z V) V | Y | Y | ||||||||
Neg() V | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y |
Not() V | Y | Y | Y | Y | Y | Y | Y | Y | ||
Or(y V) V | Y | Y | Y | Y | Y | Y | Y | Y | ||
Sqrt() V | Y | Y | ||||||||
Sub(y V) V | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y |
SubSaturated(y V) V | Y | Y | Y | Y | ||||||
Xor(y V) V | Y | Y | Y | Y | Y | Y | Y | Y |
Booleaanse en vector masking operaties (x V).Method(...) V
| Methode | Int8s | Int16s | Int32s | Int64s | Uint8s | Uint16s | Uint32s | Uint64s | Float32s | Float64s |
|---|---|---|---|---|---|---|---|---|---|---|
And(y V) V | Y | Y | Y | Y | Y | Y | Y | Y | ||
AndNot(y V) V | Y | Y | Y | Y | Y | Y | Y | Y | ||
CarrylessMultiplyEven(y V) V | Y | |||||||||
CarrylessMultiplyOdd(y V) V | Y | |||||||||
IfElse(mask MaskWs, y V) V | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y |
Masked(mask MaskWs) V | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y |
Not() V | Y | Y | Y | Y | Y | Y | Y | Y | ||
Or(y V) V | Y | Y | Y | Y | Y | Y | Y | Y | ||
Xor(y V) V | Y | Y | Y | Y | Y | Y | Y | Y |
Vergelijkingsoperaties (x V).Method(...) M
| Methode | Int8s | Int16s | Int32s | Int64s | Uint8s | Uint16s | Uint32s | Uint64s | Float32s | Float64s |
|---|---|---|---|---|---|---|---|---|---|---|
Equal(y V) MaskWs | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y |
Greater(y V) MaskWs | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y |
GreaterEqual(y V) MaskWs | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y |
Less(y V) MaskWs | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y |
LessEqual(y V) MaskWs | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y |
NotEqual(y V) MaskWs | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y |
Conversie-operaties (x V).Method(...) U
| Methode | Int8s | Int16s | Int32s | Int64s | Uint8s | Uint16s | Uint32s | Uint64s | Float32s | Float64s |
|---|---|---|---|---|---|---|---|---|---|---|
ConvertToFloatW() FloatWs | Y | Y | ||||||||
ConvertToIntW() IntWs | Y | Y | Y | Y | Y | Y | Y | Y | ||
ConvertToUintW() UintWs | Y | Y | Y | Y | Y | Y | Y | Y | ||
ToMask() (to MaskWs) | Y | Y | Y | Y | Y | Y | Y | Y |
Mask Methoden (m M).Method(...) M
| Methode | Mask8s | Mask16s | Mask32s | Mask64s |
|---|---|---|---|---|
And(y M) M | Y | Y | Y | Y |
Or(y V) V | Y | Y | Y | Y |
String() string | Y | Y | Y | Y |
ToIntWs() (to IntWs) | Y | Y | Y | Y |
Shift en rotate operaties (x V).Method() V
| Methode | Int8s | Int16s | Int32s | Int64s | Uint8s | Uint16s | Uint32s | Uint64s | Float32s | Float64s |
|---|---|---|---|---|---|---|---|---|---|---|
RotateAllLeft(dist uint64) V | Y | Y | Y | Y | Y | Y | Y | Y | ||
RotateAllRight(dist uint64) V | Y | Y | Y | Y | Y | Y | Y | Y | ||
ShiftAllLeft(dist uint64) V | Y | Y | Y | Y | Y | Y | Y | Y | ||
ShiftAllRight(dist uint64) V | Y | Y | Y | Y | Y | Y | Y | Y |
Zero-cost reshaping operaties (x V).Method(...) U
| Methode | Int8s | Int16s | Int32s | Int64s | Uint8s | Uint16s | Uint32s | Uint64s | Float32s | Float64s |
|---|---|---|---|---|---|---|---|---|---|---|
ToBits() UintWs | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y |
ReshapeToUint8s() Uint8s | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y |
ReshapeToUint16s() Uint16s | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y |
ReshapeToUint32s() Uint32s | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y |
ReshapeToUint64s() Uint64s | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y |
BitsToFloatW() FloatWs | Y | Y | ||||||||
BitsToIntW() IntWs | Y | Y | Y | Y | Y | Y | Y | Y |
Overgang naar/van platformspecifieke code
Het kan voorkomen dat het simd-pakket te beperkt is voor bepaalde delen van een applicatie, of dat er nog geen adequate emulatie is voor een benodigde functie. In dat geval ondersteunt het simd-pakket de overgang naar en van architectuurspecifieke SIMD. Elk vectortype in het simd-pakket heeft een conversiemethode ToArch() die een any teruggeeft. Deze any kan via een type-assertion worden omgezet naar een van de architectuurspecifieke typen voor een platform. Om terug te keren naar het portable type, gebruik je een van de simd.<SimdType>FromArch-functies.
Voor portable code creëert dit de verplichting om architectuurspecifieke code te schrijven voor elk van de platforms, inclusief een emulatie.
Voorbeeld: Implementatie van OnesCount (ontbreekt in Go 1.27)
Stel dat een algoritme Int8s.OnesCount() nodig heeft. In plaats van het hele algoritme per platform te herschrijven, kan alleen de ontbrekende operatie worden geïmplementeerd.
Voor amd64 (waarbij AVX/AVX2 de instructie missen, maar AVX512 deze wel heeft):
//go:build goexperiment.simd && amd64
package simd_test
import (
"simd"
"simd/archsimd"
)
var popcnt4x16 = [16]int8{0, 1, 1, 2, 1, 2, 2, 3, 1, 2, 2, 3, 2, 3, 3, 4}
var popcnt4x32 = [32]int8{
0, 1, 1, 2, 1, 2, 2, 3, 1, 2, 2, 3, 2, 3, 3, 4,
0, 1, 1, 2, 1, 2, 2, 3, 1, 2, 2, 3, 2, 3, 3, 4,
}
func OnesCount(v simd.Int8s) simd.Int8s {
switch x := v.ToArch().(type) {
case archsimd.Int8x16:
lut := archsimd.LoadInt8x16Array(&popcnt4x16)
mask0f := archsimd.BroadcastInt8x16(0x0f)
lo := x.And(mask0f)
hi := x.ToBits().ReshapeToUint16s().ShiftAllRight(4).
ReshapeToUint8s().BitsToInt8().And(mask0f)
return simd.Int8sFromArch(lut.PermuteOrZero(lo).
Add(lut.PermuteOrZero(hi)))
case archsimd.Int8x32:
lut := archsimd.LoadInt8x32Array(&popcnt4x32)
mask0f := archsimd.BroadcastInt8x32(0x0f)
lo := x.And(mask0f)
hi := x.ToBits().ReshapeToUint16s().ShiftAllRight(4).
ReshapeToUint8s().BitsToInt8().And(mask0f)
return simd.Int8sFromArch(lut.PermuteOrZeroGrouped(lo).
Add(lut.PermuteOrZeroGrouped(hi)))
case archsimd.Int8x64:
return simd.Int8sFromArch(x.OnesCount())
default:
return OnesCountEmulated(v)
}
}
De interface-conversie en type switch lijken inefficiënt, maar de compiler-implementatie van simd specialiseert de code en optimaliseert de type switch weg.
Voor NEON en Wasm (beiden ondersteunen Int8s.OnesCount()):
//go:build goexperiment.simd && (wasm || arm64)
package simd_test
import (
"simd"
"simd/archsimd"
)
func OnesCount(v simd.Int8s) simd.Int8s {
switch x := v.ToArch().(type) {
case archsimd.Int8x16:
return simd.Int8sFromArch(x.OnesCount())
default:
return OnesCountEmulated(v)
}
}
Voor platforms zonder hardware SIMD-ondersteuning:
//go:build goexperiment.simd && !(amd64 || wasm || arm64)
package simd_test
import (
"simd"
)
func OnesCount(v simd.Int8s) simd.Int8s {
return OnesCountEmulated(v)
}
De gedeelde emulatie-functie als fallback:
//go:build goexperiment.simd
package simd_test
import (
"simd"
)
func OnesCountEmulated(v simd.Int8s) simd.Int8s {
a := [2]uint64{}
v.ToBits().ReshapeToUint64s().Store(a[:])
a0, a1 := a[0], a[1]
m1 := uint64(0x5555555555555555)
m2 := uint64(0x3333333333333333)
m4 := uint64(0x0f0f0f0f0f0f0f0f)
a0 = (a0 & m1) + ((a0 >> 1) & m1)
a1 = (a1 & m1) + ((a1 >> 1) & m1)
a0 = (a0 & m2) + ((a0 >> 2) & m2)
a1 = (a1 & m2) + ((a1 >> 2) & m2)
a0 = (a0 & m4) + ((a0 >> 4) & m4)
a1 = (a1 & m4) + ((a1 >> 4) & m4)
a[0], a[1] = a0, a1
return simd.LoadUint64s(a[:]).ReshapeToUint8s().BitsToInt8()
}
API-doorsnede en methode-emulatie
Alle operaties in het simd-pakket moeten acceptabel presteren op de meeste architecturen. Elke operatie die overal wordt ondersteund, kan eenvoudig worden toegevoegd aan simd. Dit omvat meestal loads, stores, rekenkunde en vergelijkingen.
Gaten in de ondersteuning worden opgevuld door emulaties toe te voegen aan de architectuurspecifieke archsimd-API's. Veel van deze emulaties zijn triviaal:
- Opgestelde en ongestelde integer-optellingen gebruiken vaak dezelfde instructie.
- Floating point negatie en absolute waarde worden geïmplementeerd via bit-manipulatie waar nodig.
- Sommige architecturen missen ongestelde vergelijkingen; deze worden geëmuleerd met een opgestelde vergelijking plus twee XOR-operaties met een constante.
Complexere instructies, zoals "carryless multiply" (belangrijk voor cryptografie en CRC-checksums), worden ook geëmuleerd. Omdat dit vaak in crypto wordt gebruikt, is de looptijd van deze emulatie constant en onafhankelijk van de input.
In toekomstige releases zal in plaats van primitieve instructies zoals "add pairs" (horizontale optelling) direct een hoger niveau-operatie worden aangeboden, zoals sum reduction. Dit voorkomt dat gebruikers afhankelijk zijn van de vectorlengte.
GODEBUG-instellingen
Op platforms met hardware-ondersteuning kan het gedrag worden gewijzigd via GODEBUG om het testen van code met verschillende hardware-configuraties te vergemakkelijken.
In Go 1.27 worden de SIMD-ondersteuningsniveaus ruwweg beschreven door de vectorlengte:
GODEBUG=simd=0: Gebruik emulatie, zelfs als hardware-ondersteuning beschikbaar is.GODEBUG=simd=128: Gebruik 128-bit vectoren. Als functies ontbreken: panic.GODEBUG=simd=256: Gebruik 256-bit vectoren indien mogelijk.GODEBUG=simd=512: Gebruik 512-bit vectoren indien mogelijk.GODEBUG=simd=+128: Gebruik 128-bit vectoren, zelfs als sommige features ontbreken. De code paniekt alleen als een niet-ondersteunde instructie daadwerkelijk wordt aangeroepen (bijv. Raspberry Pi met NEON maar zonder PMULL).GODEBUG=simd=+256: Gebruik 256-bit vectoren, zelfs als sommige features ontbreken (bijv. Apple Silicon's amd64 emulatie met AVX2 maar zonder VPCLMULQDQ).GODEBUG=simd=+512: Gebruik 512-bit vectoren, zelfs als sommige features ontbreken.
Implementatiedetails
Het simd-pakket is een combinatie van een pakket, een interne implementatie en AST-herschrijving (Abstract Syntax Tree) in de front-end van de compiler.
De AST-herschrijving maakt meerdere gespecialiseerde kopieën van functies, variabelen en typen die simd-typen noemen. Deze worden vervangen door referenties naar grootte-gespecialiseerde typen in simd/internal/bridge. Gespecialiseerde functies krijgen een suffix zoals @simdNNN (waarbij NNN de vectorlengte is of 0 voor emulatie).
Functies die intern simd gebruiken, maar niet in hun handtekening, worden omgezet in wrappers die schakelen op het SIMD-niveau dat bij het opstarten van het programma is gedetecteerd. Deze strategie is een compromis tussen code-duplicatie en prestaties: de overhead wordt zo hoog mogelijk in de call-stack geplaatst om dispatch binnen SIMD-berekeningen te voorkomen.
Voorbeeld van dispatch optimalisatie
func BenchmarkVpsumdSIMD(b *testing.B) {
// Noem "simd" zodat de benchmark loop direct vpsumd3 aanroept
var _ simd.Uint64s
var w, x, y, z uint64 = ... // magische constanten weggelaten
var lo, hi uint64
for b.Loop() {
// vpsumd3 doet simd-dingen, maar heeft geen simd-handtekening,
// zodat het vergeleken kan worden met non-SIMD emulaties.
lo, hi = vpsumd3(w, x, y, z)
}
sinkLo, sinkHi = lo, hi
}
Wat volgt er nog
Er zal binnenkort een blogpost verschijnen die archsimd in meer detail beschrijft.
Voor Go 1.28 is het de bedoeling om SVE-ondersteuning toe te voegen aan archsimd en simd. Daarnaast worden er meer SIMD-operaties toegevoegd (bijv. OnesCount, mask-operaties, reductie-operaties en vector shuffling). Go 1.28 zal ook een klein aantal "feature-varianten" bevatten om te voorkomen dat platforms die bijna alle hardware-ondersteuning hebben (zoals Raspberry Pi), volledig moeten terugvallen op emulatie voor één of twee missende operaties.
Groetjes,