A single instruction, multiple data (SIMD) a hardverszintű párhuzamosság egyik formája. Ugyanazt a számítást a CPU egyszerre több elemen végzi el.
Vegyük például két, négy 32 bites lebegőpontos számból álló tömb elemenkénti összegzését.
Egy skaláris megvalósítás négy különálló addss utasítást adna ki, elemenként egyet.
Egy SIMD-alapú megvalósítás egyetlen addps utasítással végzi el mind a négy összeadást párhuzamosan.
Így utasításonként négyszer annyi munkát végez el.
Az x86-64 assemblyben a SIMD utasításokat jellemzően xmm regiszterekkel hajtják végre.
Ezen utasítások egy része egészeken működik.
Mások lebegőpontos számokon.
Ezeket az egészeket vagy lebegőpontos számokat nevezzük packed-nek. Mindegyik egy-egy helyet foglal el ugyanazon a regiszteren belül, amelyet lane-nek nevezünk. A SIMD utasítások jellemzően két forrásoperandus ugyanazon lane-pozícióján lévő értékein működnek párhuzamosan. Az egyes műveletek eredménye a céloperandus ugyanabban a pozíciójában lévő lane-be kerül.
A lane nem szigorú korlát. Inkább egy értelmezés, amelyet az egyes utasítások egy bájtsorozatnak adnak. Egy 128 bites operandus 16 bájtot tartalmaz. Az utasítástól függően ezek a bájtok értelmezhetők 16 1 bájtos lane-ként, 8 2 bájtos lane-ként, 4 4 bájtos lane-ként, 2 8 bájtos lane-ként, vagy akár egyetlen 16 bájtos lane-ként.
Például az addps utasítás packed egyszeres pontosságú (4 bájtos) lebegőpontos értékeket ad össze.
Ennél az utasításnál az operandus minden 4 bájtos darabja külön lane.
Egy 128 bites (16 bájtos) operandusban:
| operandus | 4. lane | 3. lane | 2. lane | 1. lane |
|---|---|---|---|---|
| 1. forrás | 1.5 | 2.1 | 6.2 | 3.4 |
| + | + | + | + | + |
| 2. forrás | 0.5 | 8.26 | -13.4 | 22.9 |
| = | = | = | = | = |
| eredmény | 2.0 | 10.36 | -7.2 | 26.3 |
Ezzel szemben egy 128 bites operandust a mulpd esetében 2 lane-ként értelmeznénk.
Ez azért van, mert ez az utasítás packed szorzást végez kétszeres pontosságú (8 bájtos) értékeken:
| operandus | 2. lane | 1. lane |
|---|---|---|
| 1. forrás | 4.2 | 2.1 |
| * | * | * |
| 2. forrás | 1.0 | 92.4 |
| = | = | = |
| eredmény | 4.2 | 194.04 |
Ebben a fogalomban a SIMD utasítások egy kis csoportjára összpontosítunk.
Ezek packed lebegőpontos értékeken működnek xmm regiszterekkel.
Ökölszabályként a lebegőpontos értékeken működő SIMD utasítások kétoperandusú formát használnak. A nevük egy meghatározott szerkezetet követ:
add, mul stb.).p utótag, amely jelzi, hogy az utasítás packed adatokon működik, ugyanazon a helyen, amelyet korábban egy s foglalt el (skaláris adatok esetén).s az egyszeres, d a kétszeres pontossághoz).A packed név tehát úgy származik a skaláris névből, hogy a mnemonik közepén lévő s-t (scalar) p-re (packed) cseréljük.
A művelet neve és a méretutótag változatlan marad:
| skaláris | packed | jelentés |
|---|---|---|
addss |
addps |
összeadás, egyszeres pontosság (32 bites lane-ek) |
addsd |
addpd |
összeadás, kétszeres pontosság (64 bites lane-ek) |
mulss |
mulps |
szorzás, egyszeres pontosság |
mulsd |
mulpd |
szorzás, kétszeres pontosság |
Az aritmetikai SIMD utasítások a fenti szabályt követik. Néhány példa:
addps xmm0, xmm1 ; xmm0 = xmm0 + xmm1 (each 32-bit float at the same position)
subpd xmm2, xmm3 ; xmm2 = xmm2 - xmm3 (each 64-bit float at the same position)
mulps xmm4, xmm5 ; xmm4 = xmm4 * xmm5 (each 32-bit float at the same position)
divpd xmm6, xmm7 ; xmm6 = xmm6 / xmm7 (each 64-bit float at the same position)
Vannak olyan SIMD utasítások is, amelyek packed egyszeres és packed kétszeres pontosságú lebegőpontos értékek között váltanak.
Ugyanazt az elnevezési szabályt követik: minden s (skaláris) helyére p (packed) kerül:
| skaláris | packed | jelentés |
|---|---|---|
cvtss2sd |
cvtps2pd |
egyszeres pontosság átalakítása kétszeresre |
cvtsd2ss |
cvtpd2ps |
kétszeres pontosság átalakítása egyszeresre |
Ezek az utasítások jól szemléltetik a korábbi megállapítást, hogy a lane értelmezés, nem szigorú korlát. A forrás- és a céloperandus lane-mérete eltér, ezért a lane-ek száma is különbözik.
A cvtps2pd esetében a forrás (ps) 4 egyszeres pontosságú lane-t tartalmaz.
A cél (pd) 2 kétszeres pontosságú lane-t.
A forrásnak csak az alsó 2 lane-jét olvassa be.
A felső 2-t figyelmen kívül hagyja:
| operandus | 4. lane | 3. lane | 2. lane | 1. lane |
|---|---|---|---|---|
forrás ps-ként |
figyelmen kívül hagyva | figyelmen kívül hagyva | 2.5 | 1.5 |
cél pd-ként |
2.5 | 1.5 |
A cvtpd2ps esetében a forrás (pd) 2 kétszeres pontosságú lane-t tartalmaz.
A cél (ps) 4 egyszeres pontosságú lane-t.
Mindkét forrás lane-t átalakítja, és a cél alsó 2 lane-jébe írja.
A cél felső 2 lane-jét nullára állítja:
| operandus | 4. lane | 3. lane | 2. lane | 1. lane |
|---|---|---|---|---|
forrás pd-ként |
4.0 | 3.0 | ||
cél ps-ként |
0 | 0 | 4.0 | 3.0 |
Néhány példa:
cvtps2pd xmm0, xmm1 ; xmm0 = xmm1 as 2 doubles (low 2 lanes of xmm1 are read)
cvtpd2ps xmm0, xmm1 ; xmm0 = xmm1 as 2 floats (in low 2 lanes; upper 2 zeroed)
A fenti SIMD utasításokat mind regiszter-regiszter operandusokkal mutattuk be. A forrásuk lehet memóriaoperandus is (a céljuk viszont nem).
A memóriából beolvasott bájtok számát az utasítás határozza meg.
Például az addps 16 bájtot (4 egyszeres pontosságú lebegőpontos számot) olvas be.
A cvtps2pd ezzel szemben csak 8 bájtot (2 egyszeres pontosságú lebegőpontos számot), mert ennyi bemenetre van szüksége.
addps xmm0, [rsi] ; xmm0 = xmm0 + 4 floats at [rsi]
mulpd xmm1, [rsi + 16] ; xmm1 = xmm1 * 2 doubles at [rsi + 16]
cvtps2pd xmm2, [rsi + 8] ; xmm2 = (2 floats at [rsi + 8]) converted to 2 doubles
Ha a memóriaoperandus 16 bájt széles, akkor 16 bájtos határra igazítottnak kell lennie. Egy igazított memóriahely olyan hely, amelynek címe a művelet méretének egész számú többszöröse (ebben az esetben 16 bájt).
Igazított memória deklarálásához használhatod az align direktívát a .data és .rodata szekcióban, illetve az alignb direktívát a .bss szekcióban:
section .rodata
align 16
mem1: dd 4.0, 3.5, 2.0, -1.0
section .data
align 16
mem2: dq 6.0, 2.1
section .bss
alignb 16
mem3: resb 16
section .text
fn:
movaps xmm0, oword [rel mem1] ; loads 4 packed 32-bit floats in xmm0
movapd xmm1, oword [rel mem2] ; loads 2 packed 64-bit floats in xmm1
movaps oword [rel mem3], xmm0 ; stores 4 packed 32-bit floats in mem3
ret
A 16 bájtnál kisebb memóriaoperandusoknak, például a cvtps2pd 8 bájtos forrásának nincs igazítási követelménye.
Négy utasítás másol 128 bitnyi packed lebegőpontos értéket.
Két xmm regiszter között, vagy egy xmm regiszter és egy memóriahely között működnek:
| utasítás | leírás |
|---|---|
movaps |
packed egyszeres pontosságú adatokat másol egy igazított memóriahelyről vagy -ra |
movups |
packed egyszeres pontosságú adatokat másol egy nem igazított memóriahelyről vagy -ra |
movapd |
packed kétszeres pontosságú adatokat másol egy igazított memóriahelyről vagy -ra |
movupd |
packed kétszeres pontosságú adatokat másol egy nem igazított memóriahelyről vagy -ra |
Mindegyik utasítás használható értékek másolására két xmm regiszter között.
Ha a művelet memóriahelyet érint, a movaps és a movapd megköveteli annak igazított voltát.
Ha nem igazított címet kapnak, hibát jeleznek.
Egyben biztonsági ellenőrzésként is szolgálnak arra, hogy az adatelrendezésed valóban olyan, amilyennek gondolod.
A movups és a movupd viszont bármilyen memóriahelyhez használható, igazítotthoz és nem igazítotthoz egyaránt.
A 16 bájtos érték méretelőtagja a NASM-ban oword.
A SIMD évtizedek óta az aktív fejlesztés célpontja az x86-64 ökoszisztémában. Az architektúra minden kiterjesztése új utasításokat vagy a régi utasítások új felhasználási módjait hozza.
Az AVX és az AVX2 kiterjesztés különösen néhány lényeges változást vezet be.
Először is megjelenik a 256 bites SIMD regiszterek, az úgynevezett ymm regiszterek támogatása.
Ezek a megfelelő xmm regisztereket terjesztik ki, nagyjából úgy, ahogy a rax tartalmazza az eax-et.
Másodszor, a legtöbb xmm (és ymm) regiszteren működő utasítás esetében támogatják a háromoperandusú változatokat.
Ez nemcsak a SIMD utasításokra igaz, hanem azokra is, amelyek skaláris lebegőpontos értékeken működnek.
Ezek a változatok ugyanazt a jelölést követik, mint az SSE-s megfelelőjük, azzal a különbséggel, hogy eléjük egy v kerül:
vaddsd xmm0, xmm1, xmm2 ; xmm0 = xmm1 + xmm2 (scalar sum of 64-bit floats)
vmulps xmm1, xmm3, xmm4 ; xmm1 = xmm3 * xmm4 (packed multiplication of 32-bit floats)
vmovupd ymm5, yword [mem] ; this copies 32 bytes from the location at mem to ymm5
Harmadszor, a SIMD memóriaoperandusokkal szemben támasztott igazítási követelmény enyhül.
Ezt az igazítást csak a kifejezetten igazított mozgatások, például a vmovaps igénylik.
Ezen a kurzuson arra szorítkoztunk, hogy legfeljebb az előző SSE kiterjesztésekig terjedő utasításokat és regisztereket használjunk. Ez elegendő az alapok megtanításához, és biztosítja a kompatibilitást a processzorok széles skálájával.
A tesztfuttató ugyanakkor teljes mértékben támogatja az AVX-et és az AVX2-t, sőt az AVX-512 részét képező újabb kiterjesztéseket is. Nyugodtan használhatod őket.
Jó gyakorlat, ha következetesen használod a SIMD változatokat. Lehetőleg ne keverd ugyanazon utasítások SSE- és AVX-változatát.
Ha a kódbázisod keveri ezeket a változatokat, jó gyakorlat a vzeroupper használata, miután végeztél az AVX-szel, és mielőtt SSE-t használnál.
Ez megszakítja a függőségi láncokat, és elkerüli a költséges leállást, amikor a CPU átvált az AVX és az SSE állapot között.
Egy autóipari alkatrészgyár gyártócsarnokának adatrendszeréhez írsz kódot. Motor- és alvázalkatrészek haladnak át egy sor megmunkáló állomáson. Minden állomás mérési adatokat állít elő, amelyeket össze kell vonni, össze kell hasonlítani, kalibrálni vagy normalizálni kell, mielőtt a következő szakasz elindul. Az itt megvalósított függvények közül egy-egy tartozik minden állomáshoz.
Ebben a feladatban a számításokat SIMD utasításokkal kell elvégezni, nem skaláris műveletekkel.
A gyártócsarnokban két párhuzamos szerelősor működik. Minden műszak végén mindkét sor jelenti a hozamát 4 alkatrészrekeszre, egyszeres pontosságú számokként. A művezető irányítópultja rekeszenként mutatja az összevont hozamot, ezért a két jelentést rekeszenként összeadjuk. Mindhárom puffert a jelentési rendszer kezeli, és mindig 16 bájtos határra igazítottak.
Definiálj egy sum_yields függvényt, amely a line_a és line_b rekeszenkénti hozamait kapva a rekeszenkénti összeget a result-ba írja.
Az argumentumok sorrendben:
line_a: 16 bájtos határra igazított memóriacím, amely 4 darab 32 bites lebegőpontos számot tartalmazline_b: 16 bájtos határra igazított memóriacím, amely 4 darab 32 bites lebegőpontos számot tartalmazresult: 16 bájtos határra igazított memóriacím, ahová a függvény 4 darab 32 bites lebegőpontos számot írA függvénynek nincs visszatérési értéke, helyette az eredmény a kapott pufferben (result) tárolódik:
line_a = {10.0, 20.0, 30.0, 40.0};
line_b = { 5.0, 15.0, 25.0, 35.0};
sum_yields(line_a, line_b, result);
// => result == {15.0, 35.0, 55.0, 75.0};
A műveletek között egy mintavevő állomás megméri a megmunkált alkatrészeket, és összehasonlítja őket a célméretekkel. A mért értékek egy precíziós mérőműszerből érkeznek egy mindig 16 bájtos határra igazított pufferbe. A célértékek a munkalapcsomagból származnak, amelynél nincs igazítási garancia. Az eltérést megszorozzuk egy tengelyenkénti érzékenységi tényezővel, mielőtt eljut a következő szakasz tűrésellenőrzőjéhez. Ebben a szakaszban a következő ellenőrző közvetlenül egy SIMD regiszterből olvassa az eltérést, nem a memóriából.
Definiálj egy scaled_deviation függvényt, amely a measured, a target és egy sensitivity alapján a skálázott eltérést a result-ba írja.
Az argumentumok sorrendben:
measured: 16 bájtos határra igazított memóriacím, amely 2 darab 64 bites lebegőpontos számot tartalmaztarget: memóriacím, amely 2 darab 64 bites lebegőpontos számot tartalmaz (az igazítás nem garantált)sensitivity: memóriacím, amely 2 darab 64 bites lebegőpontos számot tartalmaz (az igazítás nem garantált)result: memóriacím egy pufferhez, ahová a függvény 2 darab 64 bites lebegőpontos számot ír (az igazítás nem garantált)A skálázott eltérés sávonként (measured - target) * sensitivity.
A függvénynek nincs visszatérési értéke, helyette az eredmény a kapott pufferben (result) tárolódik két 64 bites lebegőpontos számként.
measured = {10.5, 20.5};
target = {10.0, 20.0};
sensitivity = { 2.0, 4.0};
scaled_deviation(measured, target, sensitivity, result);
// => result == {1.0, 2.0};
Mielőtt az alkatrészek bekerülnek a végső ellenőrző állomásra, egy 4 alkatrészből álló köteg méretadatait kétszeres pontosságúra skálázzuk át, és korrigáljuk az ismert hőmérsékleti eltolódásra.
A 4 nyers mérési érték egyszeres pontosságú számként érkezik.
Minden 2 mérési értékből álló sorra a kalibrálás kiszámítja a reference / (raw - offset) értéket, majd megszorozza ezt az eredményt egy állandó 0.5-ös átváltási tényezővel.
Figyeld meg, hogy a reference és az offset ugyanaz a köteg mindkét sorában. Az átváltási tényező minden kalibrálásnál ugyanaz, ezért ahelyett, hogy argumentumként kapnád meg, egy 16 bájtos tömörített konstansként kell deklarálnod a memóriában.
Definiálj egy calibrate_batch függvényt, amely egy raw köteget, egy reference-et, egy offset-et és egy result puffert kapva a kalibrált értékeket a result-ba írja.
Az argumentumok sorrendben:
raw: memóriacím, amely 4 darab 32 bites lebegőpontos számot tartalmaz (az igazítás nem garantált)reference: 16 bájtos határra igazított memóriacím, amely 2 darab 64 bites lebegőpontos számot tartalmazoffset: 16 bájtos határra igazított memóriacím, amely 2 darab 64 bites lebegőpontos számot tartalmazresult: 16 bájtos határra igazított memóriacím, ahová a függvény 4 darab 64 bites lebegőpontos számot ír (összesen 32 bájt)A függvénynek a köteg két sorára a következő lépéseket kell elvégeznie:
offset-et.reference-et elosztja az eredménnyel.0.5-ös átváltási tényezővel.result megfelelő 16 bájtjára menti.A függvénynek nincs visszatérési értéke, helyette az eredmény a kapott pufferben (result) tárolódik:
raw = {10.0, 20.0, 25.0, 50.0};
reference = {100.0, 100.0};
offset = { 5.0, 10.0};
calibrate_batch(raw, reference, offset, result);
// => result == {10.0, 5.0, 2.5, 1.25};
A sor végén minden alkatrésznek van egy minőségi pontszáma, amelyet normalizálni kell, mielőtt jelentik. Minden pontszámot megszorzunk a saját, alkatrészenkénti erősítésével, majd elosztunk egy tengelyenkénti gyártási léptékkel. A gains puffer alkatrészenként egy-egy erősítést tartalmaz.
A gyártási lépték az egész futásra ugyanaz, és egy olyan memóriacímben adjuk át, amely két 64 bites lebegőpontos számot tartalmaz. A pontszámok helyben frissülnek: ugyanaz a puffer, amely a nyers pontszámokat tartalmazta, most a normalizált pontszámokat tartalmazza.
Definiálj egy normalize_scores függvényt, amely egy scores tömb, egy gains tömb, egy scale és egy n darabszám alapján helyben normalizálja a pontszámokat.
Az argumentumok sorrendben:
scores: 16 bájtos határra igazított memóriacím, amely n darab 64 bites lebegőpontos számot tartalmaz (helyben módosul)gains: 16 bájtos határra igazított memóriacím, amely n darab 64 bites lebegőpontos számot tartalmazscale: 16 bájtos határra igazított memóriacím, amely 2 darab 64 bites lebegőpontos számot tartalmazn: egy 64 bites pozitív páros egész szám (minimum 2), a normalizálandó pontszámok számaA scale két sávja minden pár két elemére vonatkozik.
Az első sáv minden pár első elemét skálázza (a páros pozíciójú sávokat), a második sáv pedig a másodikat (a páratlan pozíciójú sávokat).
Minden pontszámot megszorzunk a hozzá tartozó erősítéssel, majd elosztunk a megfelelő scale-sávval: score * gain / scale_lane.
A függvénynek nincs visszatérési értéke, helyette az eredmény a bemeneti pufferben (scores) tárolódik, a normalizált score megfelelő pozíciójában:
n = 4
scores = {10.0, 20.0, 30.0, 40.0};
gains = { 2.0, 1.5, 1.0, 0.5};
scale = { 2.0, 4.0};
normalize_scores(scores, gains, scale, n);
// => scores == {10.0, 7.5, 15.0, 5.0};
Iratkozz fel az Exercism-re, hogy megtanuld és elsajátítsd a(z) x86-64 Assembly nyelvet 22 fogalom130 feladat segítségével, valódi emberi mentorálással, mindez ingyen.