12.3 A NEON regiszterkészlet és adatelrendezés

12.3.1 Motiváció

Vegyünk egy tipikus mérnöki feladatot: négy float értéket kell egy for ciklusban s skalárral szorozni. A C fordító erre a ciklusra – kapcsolókkal – néhány vektoros utasítást generálhat, de ahhoz, hogy ezt kézzel is meg tudjuk írni, először azt kell tisztázni, hol vannak a négy érték. A skaláris világban minden érték egy külön regiszterben (S0, S1, …) lakik, és az FADD/FMUL egyetlen elemet dolgoz fel. A NEON (Arm Advanced SIMD) világában viszont egy regiszter több elemet tárol párhuzamosan, ezért az első kérdés nem az aritmetika, hanem a regiszterkészlet és az adatelrendezés: hány elem fér egy regiszterbe, milyen széles egy elem, és hogyan kerülnek az értékek a memóriából a regiszter megfelelő „rekeszeibe”. Ha ezt nem rögzítjük, a vektoros utasítások szemantikája értelmezhetetlen.

Ez az alfejezet a skaláris lebegőpontos utasítások (A lebegőpontos regiszterkészlet és az IEEE 754 ábrázolás, Skaláris lebegőpontos utasítások) után a NEON keretét adja meg: a V0–V31 regiszterek nézeteit, a lane-jelölést, valamint az LD1/ST1 betöltő/tároló utasításokat. A tényleges vektoros aritmetikát a Vektorizált összeadás/szorzás: egy skaláris ciklus NEON-átirata tárgyalja; itt csak annyit használunk belőle, amennyi a nézetek bemutatásához kell.

12.3.2 Definíció: a NEON regiszterkészlet és nézetei

Az A64 ISA 32 darab 128 bites vektorregisztert definiál: V0–V31. Minden Vn regiszter egy 128 bites tár, amelyet az utasítás arrangement-je (elrendezés-jelölése) bont fel azonos méretű lane-ekre (sávokra). Egy Vn regisztert egyetlen utasítás egyszerre 128, 64, 32, 16 vagy 8 bites nézetben ér el, de egyszerre csak egy nézetben.

A jelölés: Vn.<darabszám><típus>, ahol a típusbetű a lane méretét adja meg:

Típusbetű Lane méret Lehetséges nézetek (128 bit) 64 bites nézet
B 8 bit (byte) Vn.16B (16 sáv) Vn.8B (8 sáv)
H 16 bit (halfword) Vn.8H (8 sáv) Vn.4H (4 sáv)
S 32 bit (single) Vn.4S (4 sáv) Vn.2S (2 sáv)
D 64 bit (double) Vn.2D (2 sáv) Vn.1D (1 sáv)

A szabály kötelező: a darabszám × lane szélesség = 128 vagy 64 bit. Ezért V0.4S, V0.8H és V0.16B ugyanannak a 128 bites tárnak a három különböző felbontása; bármelyik használható, de az utasításnak és az elrendezés-jelölésnek összhangban kell lenniük.

A lane-ek indexelhetők is: Vn.<T>[i], ahol i a 0 és a darabszám-1 közötti sávszám. A 0. lane a legkisebb helyértékű biteket foglalja el. Ez azért fontos, mert little-endian tárolásnál az LD1 betöltés során a memória legkisebb címén lévő elem kerül a 0. lane-be, és így tovább növekvő címekkel növekvő lane-indexbe. Ha az értelmezés mégis fordítottnak tűnik, az szinte mindig endianness- vagy lane-számozási félreértés (Bájtsorrend: endianness és miért releváns a határon).

Definíció. NEON-lane: egy Vn vektorregiszter egyetlen, B/H/S/D méretű, a megadott nézet szerinti sávja; az utasítások lane-enként (SIMD-módon) párhuzamosan dolgoznak fel egy Vn.<darabszám><T> elrendezésben.

Megjegyzés: a Qn a 128 bites egész (Vn.16B megfelelője), a Dn a 64 bites alsó rész (Vn.8B), az Sn/Hn/Bn pedig a legalacsonyabb 32/16/8 bit skaláris nézete. NEON-utasításoknál a Vn.<T> alakot írjuk.

12.3.3 Működés: betöltés és tárolás, lane-ek közötti mozgatás

12.3.3.1 LD1/ST1 – vektoros betöltés és tárolás

A LD1 (load single structure) egy vagy több Vn regiszterbe tölt összefüggő memóriából, a ST1 (store single structure) ugyanígy tárol. Alapszintaxis (A64):

ld1   {v0.4s}, [x1]            // 16 bájtot tölt v0-ba, 4 db 32 bites lane
ld1   {v0.16b}, [x1]           // ugyanaz, byte-nézetben
ld1   {v0.2d}, [x1]            // 16 bájt, 2 db 64 bites lane
ld1   {v0.2s}, [x1]            // CSAK 8 bájtot tölt (2 db 32 bites lane)
st1   {v0.4s}, [x0]            // 16 bájtot tárol
ld1   {v0.4s}, [x1], #16       // post-index: x1 += 16 a betöltés után
ld1   {v0.4s, v1.4s}, [x1]     // 32 bájtot tölt (v0-ba az első 16, v1-be a második 16)

A kulcsészrevétel: az LD1 által átvitt bájtszám a nézetből következik, nem külön paraméter. A {v0.4s} és a {v0.16b} ugyanazt a 16 bájtot tölti be, csak az értelmezést adja meg. A {v0.2s} ezzel szemben csak 8 bájtot mozgat – az assembler nem kifogásolja, de ha 16 bájtot szándékoztunk olvasni, hibás programot kapunk, ami adatszegmens-túllépésbe futhat.

Az LD1 „single structure” változat; a rokon LD2/LD3/LD4 ugyanabból a memóriából összefésült (interleaved) adatot bont szét több regiszterbe. Ezt a Struktúrák, igazítás és mezőelérés mintázatai után érdemes használni; jelen alfejezetben maradunk az egyregiszteres formánál.

12.3.3.2 Lane-ek közötti mozgatás: DUP, INS, MOV

Három egyszerű eszköz arra, hogy egy lane-t értékkel lássunk el:

Visszafelé, egy lane-t általános célú regiszterbe a umov/smov visz (umov w3, v0.s[2]).

Az ins célregisztert nem inicializálja: a nem érintett lane-ek értéke definiálatlan, amíg oda nem írunk. Ezért lane-enkénti összeállítás előtt rendszerint egy mov v1.16b, v0.16b másolással indulunk.

12.3.3.3 Miért számít az igazítás?

A64-en az általános betöltők (köztük a NEON LD1/ST1) alapértelmezés szerint megengedik a nem igazított címeket, amennyiben a rendszer SCTLR.A = 0 (ez a szokásos user-mode Linux és a QEMU user-mode konfigurációja). Ez azt jelenti, hogy az igazítás nem korrektségi, hanem teljesítménykérdés:

Ezért a vektoros adatszerkezeteket érdemes _Alignas(16) (C) vagy .balign 16 (assembly) direktívával 16 bájtos határra igazítani. Azt, hogy egy konkrét mikroarchitektúrán mennyi a többletköltség, az adott mag dokumentációjából vagy mérésből kell igazolni, nem az ISA garantálja.

12.3.3.4 AAPCS64 és a V-regiszterek

A C-interoperabilitás miatt jegyezzük meg: az AAPCS64 szerint a V0–V7 és a V16–V31 caller-saved; a V8–V15-nek csak az alsó 64 bitje callee-saved. Ez azt jelenti, hogy egy híváson át egyetlen V-regiszter teljes 128 bitjében sem bízhatunk. A V0–V7 és V16–V31 egészét, a V8–V15 felső 64 bitjét a hívott szabadon felülírhatja. Ha a hívónak egy teljes 128 bites értékre a hívás után is szüksége van, azt a hívás előtt 16 bájtra igazított veremhelyre kell mentenie, vagy a hívás után újra kell számolnia. Ezt gyakran elfelejtik. Részletek a Az AAPCS64 regiszterszerződés-ben.

12.3.4 Kidolgozott példa

Munkakönyvtár: ~/arm-labor/a64/a046/ — az alfejezet minden laborfájlja ide kerül, és minden parancs innen futtatandó (a könyvtárrendet a Labor-alapkörnyezet alfejezet írja le).

mkdir -p ~/arm-labor/a64/a046
cd ~/arm-labor/a64/a046

Írjunk egy assembly függvényt, amely betölt négy float-ot LD1-gyel, MOV+INS segítségével megcseréli a két szélső lane-t, majd ST1-gyel visszaírja. Ez tisztán a regiszter/lane kezelést demonstrálja, aritmetika nélkül.

// lab-swapends.S
// void lab_swapends(float *dst, const float *src);
//   dst[0..3] = { src[3], src[1], src[2], src[0] }
        .text
        .global lab_swapends
        .type   lab_swapends, %function
lab_swapends:
        ld1     {v0.4s}, [x1]           // v0 = {s0, s1, s2, s3}  (lane 0..3)
        mov     v1.16b, v0.16b          // v1 = teljes másolat
        ins     v1.s[0], v0.s[3]        // v1.s[0] = s3
        ins     v1.s[3], v0.s[0]        // v1.s[3] = s0
        st1     {v1.4s}, [x0]           // dst[0..3] = {s3, s1, s2, s0}
        ret
        .size   lab_swapends, .-lab_swapends

        .section .note.GNU-stack,"",%progbits

A C-oldali driver és a build (cross-toolchain + QEMU user-mode):

/* lab-swapends-main.c */
#include <stdio.h>

extern void lab_swapends(float *dst, const float *src);

int main(void)
{
    _Alignas(16) float src[4] = {1.0f, 2.0f, 3.0f, 4.0f};
    _Alignas(16) float dst[4] = {0};
    lab_swapends(dst, src);
    for (int i = 0; i < 4; ++i)
        printf("%g\n", (double)dst[i]);
    return 0;
}
aarch64-linux-gnu-gcc -static -O0 -g -o lab-swapends lab-swapends.S lab-swapends-main.c
# elvárt: GNU_STACK RW
readelf -lW lab-swapends \
  | awk '$1=="GNU_STACK"{f=$7} END{print "GNU_STACK", f; exit !(f=="RW")}'
qemu-aarch64 ./lab-swapends

Várt kimenet (hypotetikus, a hallgató laborjában ellenőrizendő):

4
2
3
1

Regiszterállapot ellenőrzése GDB-vel (a lényeg a lane-szemlélet):

qemu-aarch64 -g 1234 ./lab-swapends   # az egyik terminálban
# A másik terminálban:
gdb-multiarch ./lab-swapends
(gdb) target remote localhost:1234
(gdb) break lab_swapends
(gdb) continue
(gdb) info registers v0 v1
(gdb) si                 # ld1 után: v0.4s = {1,2,3,4}
(gdb) p/x $v0            # a teljes 128 bites bitminta

A 0. lane 0x3F800000 (1.0f), a 3. lane 0x40800000 (4.0f) bitmintája igazolja, hogy a memória kis címe a 0. lane-be került (little-endian). A GDB vektorregiszter-nézetének mezőnevei verziónként eltérhetnek.

12.3.5 Tipikus hibák és ellenpróbák

12.3.6 Feladatok

1. (magyarázó, alapszint — cél: megértés-ellenőrzés) Adott egy V0 nézet V0.4S, ahol V0 = 0x40490FDB_3F800000_00000000_C0490FDB (hex). Add meg V0.S[0], V0.S[1], V0.S[3] IEEE 754 szerinti közelítő értékét, és indokold, hogy a bájt-nézetben V0.B[0] és V0.B[4] miért különböző lane-ekhez tartozik.

2. (implementációs, alapszint — cél: rutinépítés) Írj egy lab_bcast4(float *dst, float s) assembly függvényt, amely az s skalárt DUP-pal mind a négy lane-be terjeszti (fmov w1, s0 → dup v0.4s, w1), majd ST1-gyel dst[0..3]-ba írja. Készíts hozzá C drivert és futtasd qemu-aarch64-gyel.

3. (hibakeresési/mérési, középszint — cél: rutinépítés) Az alábbi szándékosan hibás kódrészlet csak két forráselemet tölt be, és a várt megfordítás helyett a dst[3] nulla marad. Add meg a hiba okát, és javítsd úgy, hogy dst = {src[3], src[2], src[1], src[0]} legyen. Használj gdb-t a regisztertartalom ellenőrzéséhez.

lab_rev4:
    ld1     {v0.2s}, [x1]        // FELTÉTELEZETTEN hibás
    mov     v1.16b, v0.16b
    ins     v1.s[0], v0.s[1]
    ins     v1.s[1], v0.s[0]
    st1     {v1.4s}, [x0]
    ret

12.3.7 Megoldások

1. megoldás — Segítség. Bontsd a 128 bitet négy darab 32 bites lane-re a hexadecimális mintából, és minden lane bitmintáját olvasd IEEE 754 egyszeres pontosságú kódolásként — a bájt-indexelés little-endian, ez dönti el, melyik bájt melyik lane-hez tartozik.

1. megoldás — Teljes megoldás. Bontsuk 32 bites lane-ekre: S0 = 0xC0490FDB, S1 = 0x00000000, S2 = 0x3F800000, S3 = 0x40490FDB. IEEE 754 szerint: S0 ≈ −3.1415927 (0xC0490FDB a −π bitmintája), S1 = +0.0, S3 ≈ +3.1415927. A V0.B[0] a S0 legalacsonyabb bájtját jelenti (little-endian: a legalacsonyabb helyértékű bájt), V0.B[4] pedig a S1 legalacsonyabb bájtját; ugyanabban a 128 bites tárban más lane-hez tartoznak, csak a bontás más.

2. megoldás — Segítség. A dup a skalár bitmintáját egy általános célú regiszteren keresztül terjeszti szét — a lépéssorrend: skalár → GP-regiszter → DUP → ST1.

2. megoldás — Teljes megoldás.

// void lab_bcast4(float *dst, float s)
        .text
        .global lab_bcast4
        .type   lab_bcast4, %function
lab_bcast4:
        fmov    w1, s0              // s bitmintája GP regiszterbe
        dup     v0.4s, w1           // minden lane = s
        st1     {v0.4s}, [x0]       // 16 bájt kiírása
        ret
        .size   lab_bcast4, .-lab_bcast4

        .section .note.GNU-stack,"",%progbits

Driver és build hasonló az előző példához; a várt kimenet s négyszer. A dup v0.4s, w1 a bitmintát négyszer másolja, ami a lebegőpontos s-re pontosan s-t ad minden lane-ben, mert a forrás bitminta már helyes IEEE 754 kódolás.

3. megoldás — Segítség. Nézd meg pontosan, hány bájtot tölt be az ld1 {v0.2s} forma a kétregiszteres v0.4s nézethez képest — a hiányzó lane-ek innen erednek.

3. megoldás — Teljes megoldás. Az ld1 {v0.2s} csak 8 bájtot tölt be; az utasítás a felső 64 bitet nullázza, nem a másik két forráselemet tölti oda. Emellett a két INS csak az első két elemet cseréli fel. A teljes megfordításhoz mind a négy forráselem kell. A REV64 a két 64 bites félen belül cserél, az EXT #8 pedig felcseréli a feleket:

lab_rev4:
    ld1     {v0.4s}, [x1]
    rev64   v0.4s, v0.4s
    ext     v0.16b, v0.16b, v0.16b, #8
    st1     {v0.4s}, [x0]
    ret

Ellenőrzés: {1,2,3,4} bemenetre {4,3,2,1} az eredmény. A feladat szándékosan hibás kódja fent megmarad.

12.3.8 Rövid összefoglaló

A NEON 32 db 128 bites Vn regisztere ugyanazt a tárat több nézetben mutatja: Vn.16B, Vn.8H, Vn.4S, Vn.2D (és feleakkora 64 bites változataik). A darabszám × lane szélesség = 128 vagy 64 bit, a 0. lane a legalacsonyabb helyértékű – little-endian memóriában ez a legalacsonyabb cím. Az LD1/ST1 a nézetből vezeti le az átvitt bájtszámot; a DUP, INS és MOV a lane-ek közötti mozgatás eszközei. Az igazítás nem korrektségi, de teljesítményt érintő tényező. AAPCS64 szerint V0–V7 és V16–V31 caller-saved, V8–V15 csak alsó 64 bitjében callee-saved.

Továbbhasznált definíciók: NEON-lane; arrangement (elrendezés-jelölés: Vn.<darabszám><T>); LD1/ST1 (egyregiszteres vektoros betöltés/tárolás).

12.3.9 Források és ellenőrzési hiányok

A linkek és a dokumentumverziók közös állapota az Olvasói útmutató forrásállapot-táblájában van; itt csak az alfejezet saját megállapításai szerepelnek.

Ellenőrzési hiányok, amelyek a fenti ellenőrzés után is fennmaradnak: a GNU eszközlánccal és qemu-aarch64-gyel a kódot itt nem futtattuk. A helyettesítő eszközlánccal (Clang + musl, statikus build, Unicorn-alapú emulátor) a lab-swapends a 4 2 3 1 kimenetet adta, a kész ELF GNU_STACK jelzője RW; a teljes vektor veremmentésének ellenpróbáját (lásd Tipikus hibák) ugyanígy futtattuk. A GDB-menet levezetett, várt állapot. Az Ubuntu 24.04-re telepített GCC/binutils/QEMU pontos verzióját a laborban kell rögzíteni.

Nyitott ellenőrzési igény: az SCTLR.A = 0 alapértelmezés és az igazítatlan LD1 viselkedése a konkrét QEMU-verzióban és a fizikai AArch64 hoston; a cache-sor határán átnyúló hozzáférés ciklusköltsége az adott mikroarchitektúrán. A példák várt kimenete hipotetikus/levezetett, a tényleges ellenőrzés a hallgató laborjában történik. Az LD2/LD3/LD4 és a TBL részletes tárgyalása a Vektorizált összeadás/szorzás: egy skaláris ciklus NEON-átirata és a NEON-gyorsulás mérése és korlátai témája.