12.4 Vektorizált összeadás/szorzás: egy skaláris ciklus NEON-átirata

12.4.1 Motiváció

Képfeldolgozó vagy jelfeldolgozó kódban gyakran ilyen ciklus áll: két int32_t tömböt elemenként összeadunk egy harmadikba. A skaláris változat minden elemre külön ldr/ldr/add/str sorozatot hajt végre. Egy modern AArch64 mag egy órajel alatt akár 128 bitet is be tud tölteni, ha a kód erre alkalmas alakban kéri – a skaláris ciklus viszont elemekre bontja azt a szélességet, és a betöltési sáv szélességét nem használja ki.

A feladat: ugyanazt a szemantikát („c[i] = a[i] + b[i] minden i-re”) úgy megfogalmazni, hogy egyetlen utasítás egyszerre négy int32_t-n dolgozzon, miközben a tömb hossza tetszőleges – nem kell 4 többszörösének lennie, és 0 is lehet. Ez a NEON-átirat (NEON rewrite) magja, és innentől a skaláris ciklus csak referencia-implementációként szolgál a korrektség ellenőrzéséhez.

12.4.2 Definíció és működés

Vektorizálás (vectorization): egy skaláris művelet újrafogalmazása úgy, hogy egy utasítás egyszerre S=128/wS = 128 / w darab ww bites adatelemen hajtsa végre ugyanazt a műveletet, ahol SS a lane-ek száma. int32_t esetén w=32w = 32, tehát S=4S = 4. A Vn.4S elrendezés pontosan ezt a négy 32 bites sávot jelenti (lásd A NEON regiszterkészlet és adatelrendezés).

A vektorizálhatóság feltétele az adatforma-függetlenség (element-wise independence): az eredmény ii-edik eleme kizárólag a bemenetek ii-edik elemétől függ. Az add ilyen úgynevezett map minta; a prefix-összeg vagy a futó maximum viszont nem, mert ott a szomszédos elemekre is szükség van. Ez a megkülönböztetés később, a szorzás-összeadás (multiply-accumulate) mintánál is fontos marad.

A fő ciklus / maradékciklus szerkezet. Jelölje nn az elemek számát. Ha nn nem osztható SS-sel, a ciklust ketté kell bontani:

nfő=S⋅⌊nS⌋,nmaradék=n−nfő=nmodS n_{\text{fő}} = S \cdot \left\lfloor \frac{n}{S} \right\rfloor, \qquad n_{\text{maradék}} = n - n_{\text{fő}} = n \bmod S

A fő ciklus nfőn_{\text{fő}} elemet dolgoz fel, SS elemes lépésekkel, vektoros utasításokkal; a maradékciklus (remainder loop) a maradék nmaradék∈{0,…,S−1}n_{\text{maradék}} \in \{0,\dots,S-1\} elemet skalárisan. Pszeudokód:

i = 0
while i < n_fő:      # vektoros ág
    c[i..i+3] = a[i..i+3] + b[i..i+3]
    i += 4
while i < n:         # maradék ág
    c[i] = a[i] + b[i]
    i += 1

Fontos megfigyelés: nfő=n&∼(S−1)n_{\text{fő}} = n \,\&\, \sim(S-1), azaz int32_t-re n&∼3n \,\&\, \sim 3, ami kettes komplemensben n&−4n \,\&\, -4 – bitmanipulációval (n >> 2) << 2 alakban is megkapható (lásd Shift és rotate: LSL/LSR/ASR/ROR gyakorlati mintázatok). Az alábbi táblázat néhány nn-re mutatja a felosztást; az értékek számítottak, nem mérték:

nn ⌊n/4⌋\lfloor n/4 \rfloor nfőn_{\text{fő}} (elem) nmaradékn_{\text{maradék}}
0 0 0 0
7 1 4 3
8 2 8 0
17 4 16 1

Két tervezési döntés van, amelyet érdemes tudatosan meghozni. Az egyik az indexelés egysége: számolhatunk elemben (és a címzéshez lsl #2 skálázást használunk), vagy bájtban (ekkor a lépés #16 a fő és #4 a maradék ciklusban). Elemszám-alapú indexelésnél a fő ciklus határa közvetlenül nfőn_{\text{fő}}, bájtalapúnál 4nfő4n_{\text{fő}}. A másik, hogy a határt előre kiszámítjuk-e, vagy minden iterációban újraszámoljuk – az előbbi rövidebb és gyorsabb ciklusmagot ad.

12.4.3 Kidolgozott példa

Munkakönyvtár: ~/arm-labor/a64/a047/ — 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/a047
cd ~/arm-labor/a64/a047

Az alábbi kód A64 ISA (AArch64, ARMv8-A alapprofil), az AAPCS64 szerint x0 = a, x1 = b, x2 = c, x3 = n. A rutinok void visszatérésűek, csak a c tömböt írják.

Nevek és interfész. A függvénynevekben az i32 az elemtípust jelöli. A következő alfejezet float tömbökkel és más paramétersorrenddel (dst, a, b, n) dolgozik, és ott a függvények add_f32_* nevet kapnak. Azonos név esetén egy régebbi, más interfészű objektum észrevétlenül belinkelődhetne. A deklarációk közös fejlécbe kerülnek, lab-a047-add-i32.h néven:

/* lab-a047-add-i32.h */
#ifndef LAB_A047_ADD_I32_H
#define LAB_A047_ADD_I32_H
#include <stddef.h>
#include <stdint.h>

/* c[i] = a[i] + b[i], 0 <= i < n.
 * Előfeltétel: a, b, c legalább n elemű, érvényes tömbök (n = 0 esetén
 * nem dereferáljuk őket); c nem fedi át részlegesen a-t vagy b-t; minden
 * i-re INT32_MIN <= a[i] + b[i] <= INT32_MAX (a C-referencia csak így definiált). */
void add_i32_scalar(const int32_t *a, const int32_t *b, int32_t *c, size_t n);
void add_i32_neon  (const int32_t *a, const int32_t *b, int32_t *c, size_t n);

#endif

A túlcsordulási tartomány. Az assembly ADD W5, W5, W6 és a vektoros ADD V0.4S, … minden bemeneten modulo 2322^{32} számol, a két változat tehát a teljes tartományon bitre azonos eredményt ad. A C-referencia a[i] + b[i] kifejezése viszont int32_t operandusokkal (amelyek int-té léptetődnek elő) előjeles túlcsordulásnál definiálatlan viselkedés. A „C-vel egyenértékű” állítás ezért csak a fejlécben rögzített tartományon igaz; a teszt bemenetei ezen belül maradnak.

Az elvárt viselkedést a fejléc C-szerződése rögzíti: c[i] = a[i] + b[i]. A teszt ezt a C-kifejezést elemenként maga számolja ki, és orákulumként használja. Mindkét assembly rutint külön-külön ehhez hasonlítja, nem egymáshoz. Először a skaláris változat assemblyben:

// lab-a047-scalar.s
    .text
    .global add_i32_scalar
    .type   add_i32_scalar, %function
add_i32_scalar:
    cbz     x3, .Lsc_done
    mov     x4, #0
.Lsc_loop:
    ldr     w5, [x0, x4, lsl #2]
    ldr     w6, [x1, x4, lsl #2]
    add     w5, w5, w6
    str     w5, [x2, x4, lsl #2]
    add     x4, x4, #1
    cmp     x4, x3
    b.lo    .Lsc_loop
.Lsc_done:
    ret
    .size   add_i32_scalar, . - add_i32_scalar

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

A NEON-változat ugyanezt a szerkezetet követi, csak a fő ciklusban vektorosan:

// lab-a047-neon.s
    .text
    .global add_i32_neon
    .type   add_i32_neon, %function
add_i32_neon:
    mov     x4, #0              // i = 0 (elemben)
    lsr     x5, x3, #2          // n / 4        (teljes csoportok)
    lsl     x5, x5, #2          // n_fő = 4 * (n/4) elemben
    cbz     x5, .Lnv_rem        // n < 4 -> nincs fő ciklus
.Lnv_main:
    lsl     x9, x4, #2              // bájteltolás = i*4; q-címzéshez külön képezve
    ldr     q0, [x0, x9]            // a[i..i+3]
    ldr     q1, [x1, x9]            // b[i..i+3]
    add     v0.4s, v0.4s, v1.4s     // 4 párhuzamos 32 bites összeadás
    str     q0, [x2, x9]            // c[i..i+3]
    add     x4, x4, #4              // 4 elemmel lépünk
    cmp     x4, x5
    b.lo    .Lnv_main
.Lnv_rem:
    cmp     x4, x3
    b.hs    .Lnv_done           // nincs maradék
.Lnv_rloop:
    ldr     w6, [x0, x4, lsl #2]
    ldr     w7, [x1, x4, lsl #2]
    add     w6, w6, w7
    str     w6, [x2, x4, lsl #2]
    add     x4, x4, #1
    cmp     x4, x3
    b.lo    .Lnv_rloop
.Lnv_done:
    ret
    .size   add_i32_neon, . - add_i32_neon

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

A ldr q0, [x0, x4, lsl #2] alak nem assemblálható: 128 bites (q) regiszteres átvitelnél a regiszter-offset lsl mezője a kódolásban csak #0 vagy #4 (log2(16)) lehet, mert a hardver csak a tényleges, 16 bájtos transzferméretnek megfelelő vagy nulla skálázást enged meg — #2 (4 bájtos skálázás) ennél az utasításformánál nem létező kódolás. Mivel x4 itt elemindex (int32_t, 4 bájt/elem), a bájteltolást (i*4) külön lsl x9, x4, #2 utasítással kell kiszámítani, és azt shift nélkül, [Xn, X9] alakban kell a q-címzésben felhasználni. A skaláris (w, 4 bájtos) hozzáférésnél ugyanez a lsl #2 közvetlenül a címzésben érvényes, mert ott a transzferméret pontosan a shift által jelzett 4 bájt.

Regiszterhasználat: x0–x2 a paraméterek, x3 a hossz, x4 a futó index, x5 a fő ciklus elemszám-határa, x6/x7 skaláris temporálisok, x9 a q-címzéshez külön képezett bájteltolás, q0/q1 a vektoros operandusok. Mind x4–x9, mind v0/v1 caller-saved, ezért nem kell menteni őket (lásd Az AAPCS64 regiszterszerződés).

A korrektségi teszt minden n∈[0,17]n \in [0,17]-re lefut, így mind a négy lehetséges maradékosztály (0, 1, 2, 3) lefedésre kerül. A két rutint külön-külön egy független C-orákulumhoz méri. A két rutin egymáshoz hasonlítása csak az eltérésüket mutatná meg: egy mindkettőben azonos paramétersorrend-, művelet- vagy indexhiba így észrevétlen maradna. A kimeneti tömb előtt és után négy-négy őrszó áll, és a c minden eleme előre ugyanezzel az őrértékkel töltődik. Így a meg nem írt elem és a tömbön kívüli írás is lebukik. n = 0-ra a teszt NULL mutatókat ad át, tehát bármilyen memória-hozzáférés laphibát okozna.

/* lab-a047-main.c — mindkét rutint külön-külön egy független C-orákulumhoz méri */
#include <stdint.h>
#include <stdio.h>
#include "lab-a047-add-i32.h"

#define NMAX  17
#define GUARD 4                         /* őrszavak c előtt és után */
#define GVAL  ((int32_t)0x5A5A5A5A)     /* őrérték, egyben a c előtöltése */

typedef void (*add_fn)(const int32_t *, const int32_t *, int32_t *, size_t);

/* 0, ha f(a, b, c, n) pontosan c[i] = a[i] + b[i]-t írt minden i < n-re, és
   mást nem; a c-n kívüli őrszavak változatlanok. n = 0-ra NULL mutatókat kap:
   bármilyen hozzáférés laphiba. */
static int check(const char *name, add_fn f, size_t n)
{
    int32_t a[NMAX], b[NMAX], buf[GUARD + NMAX + GUARD];
    int32_t *c = buf + GUARD;
    for (size_t i = 0; i < n; i++) {
        a[i] = (int32_t)(i * 3 + 1);        /* minden elem különböző */
        b[i] = (int32_t)i * 5 - 2;          /* |a[i] + b[i]| < 200: nincs túlcsordulás */
    }
    for (size_t k = 0; k < GUARD + NMAX + GUARD; k++)
        buf[k] = GVAL;
    if (n == 0)
        f(NULL, NULL, NULL, 0);
    else
        f(a, b, c, n);
    for (size_t k = 0; k < GUARD + NMAX + GUARD; k++) {
        int in_c = k >= GUARD && k - GUARD < n;
        int32_t want = in_c ? a[k - GUARD] + b[k - GUARD] : GVAL;   /* C-orákulum */
        if (buf[k] != want) {
            printf("%s n=%zu: %s, c-index %ld: %d (elvárt %d)\n", name, n,
                   in_c ? "hibás elem" : "írás a c-n kívül", (long)k - GUARD, buf[k], want);
            return 1;
        }
    }
    return 0;
}

int main(void)
{
    int fails = 0;
    for (size_t n = 0; n <= NMAX; n++) {
        int bad = check("add_i32_scalar", add_i32_scalar, n)
                | check("add_i32_neon",   add_i32_neon,   n);
        if (!bad)
            printf("n=%2zu OK\n", n);
        fails += bad;
    }
    puts(fails ? "TEST: FAIL" : "TEST: PASS");
    return fails != 0;
}

Build és futtatás (target: qemu-aarch64, user-mode emuláció; a statikus link azért kell, hogy a guest dinamikus linkere ne hiányozzon):

aarch64-linux-gnu-as -o lab-a047-scalar.o lab-a047-scalar.s
aarch64-linux-gnu-as -o lab-a047-neon.o   lab-a047-neon.s
aarch64-linux-gnu-gcc -O2 -c -o lab-a047-main.o lab-a047-main.c
aarch64-linux-gnu-gcc -static -o lab-a047-test \
    lab-a047-main.o lab-a047-scalar.o lab-a047-neon.o
# elvárt: GNU_STACK RW
readelf -lW lab-a047-test \
  | awk '$1=="GNU_STACK"{f=$7} END{print "GNU_STACK", f; exit !(f=="RW")}'
qemu-aarch64 ./lab-a047-test; echo "exit=$?"

Várt kimenet: 18 sor n= 0 OK … n=17 OK, majd TEST: PASS és exit=0. Bármely eltérésnél a program megnevezi a rutint, az n-t és az első hibás c-indexet, megkülönbözteti a hibás elemet a c-n kívüli írástól, és 1-es kilépési kóddal áll le.

Ellenpróba: a teszt valóban elkapja a hibát. A helyettesítő eszközlánccal (Clang + musl, Unicorn-alapú emulátor) mérve:

Beültetett hiba Megfigyelt eredmény
nincs (helyes rutinok) n= 0 OK … n=17 OK, TEST: PASS, exit=0
add → sub mindkét rutinban, ugyanúgy add_i32_scalar n=1: hibás elem, c-index 0: 3 (elvárt -1), ugyanígy az add_i32_neon; TEST: FAIL
a NEON-maradékciklus feltétele b.lo helyett b.ls (egy elemmel túlír) add_i32_neon n=1: írás a c-n kívül, c-index 1; TEST: FAIL

Az első sor azt mutatja, miért kell független orákulum: egy korábbi változat a két rutint egymáshoz hasonlította, és ugyanezzel a kétoldali add → sub hibával hibátlanul lefutott.

Ellenőrzési lépés: aarch64-linux-gnu-objdump -d lab-a047-neon.o kimenetében a q0-ás betöltés, az add v0.4s, ... és a str q0, ... sorok mellett a ciklusmag hossza vizuálisan is látható; ha a fordító/assembler négy ldr w-t mutat helyette, akkor nem a NEON-ág fordult le.

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

A maradékciklus hiánya. A leggyakoribb tünet: n=4kn = 4k hosszon minden helyes, n=17n = 17-nél viszont a 16. elem (index 16) változatlan marad. Hipotézis: a fő ciklus után nincs while i < n második ciklus. Adatgyűjtés: gdb-multiarch alatt töréspont a .Lnv_done előtt, majd p $x4, p $x3; ha $x4 != $x3, maradék elem maradt. Ellenpróba: tegyük be a hiányzó maradék ágat, és futtassuk újra a 0–17-es tesztet.

Mértékegység-keveredés a ciklushatárban. Ha a lsl x5, x5, #2 kimarad, akkor x5 = n/4 csoport, miközben x4 elemben nő #4-gyel. n=17n = 17-re x5 = 4 lesz, és a fő ciklus az első iteráció után kilép. A teljes vezérlési út azonban itt nem ér véget: a maradékciklus az aktuális x4 = 4-től n-ig fut, és a 4–16. elemet skalárisan elkészíti. Az eredmény tehát helyes, a korrektségi teszt átmegy; 4 elem készül vektorosan és 13 skalárisan. A hiba teljesítménybeli: n=17n = 17-re a helyettesítő eszközlánccal megszámolt dinamikus utasításszám 46 helyett 105. Ez az egyik legtanulságosabb hiba, mert a korrektségi teszt nem látja, csak a vezérlési út vizsgálata vagy az utasításszám. Ha egy dimenzióhiba korrektségi hibát is okoz, az csak akkor jön elő, ha a maradékág sem a futó indexből indul. Ezt is a teljes vezérlési úton kell ellenőrizni, nem a fő ciklusban magában.

Skálázási hiba. Ha a lsl x9, x4, #2 sor kimarad, és a q-betöltés/tárolás közvetlenül x4-et (az elemindexet) használja bájteltolásként ([x0, x4]), akkor a tényleges cím iterációnként csak 4 bájttal (egy elemmel) mozdul a helyes 16 bájt (négy elem) helyett — a négy lane nagyrészt ugyanazt, egymást átfedő bemenetet dolgozza fel többször, és az eredmény az elemek döntő részénél hibás, miközben a program nem áll le hibával. Ellenpróba: olyan mini-teszttel érdemes kezdeni, ahol az a és b tömb elemei mind különbözőek (ahogy a fenti harnessben) — ekkor az átfedő olvasás azonnal hibás elem-ként látszik.

b.lo vs. b.lt. x4, x3, x5 mind unsigned összehasonlításban értelmezendő (indexek, nem előjeles mennyiségek), ezért a helyes feltételkód lo/hs. Ha véletlenül lt-t írunk, akkor nn nagy (a 63. bitet elérő) értékeinél az elágazás rossz irányba megy; degenerált tesztadatokkal ez nem derül ki.

Előjeles túlcsordulás a referenciában. A C skaláris változat int32_t összeadása előjeles túlcsordulásnál undefined behavior (UB), a NEON add v0.4s viszont definiáltan modulo 2322^{32} működik. A korrektségi teszt ezért olyan értékeket használjon, ahol nincs túlcsordulás, vagy uint32_t-et (illetve a NEON-ban add v0.4s helyett add v0.4s-t azonos bitmintán) – a szemantikai különbséget dokumentálni kell.

n = 0 eset. A cbz x5, .Lnv_rem nélkül a cmp x4, x5 eleve hamis lenne, de a str a cbz hiányában egyszer még lefutna egy nulla hosszú tömbre. A nulla hossz explicit kezelése nem stílus kérdése: a do-while szerkezetű assembly-ciklusok tipikus hibája.

12.4.5 Feladatok

1. feladat (magyarázó, alapszint — cél: megértés-ellenőrzés). Az add_i32_neon fő ciklusa do-while szerkezetű: az index növelése a cmp előtt történik. Fogalmazza meg szavakkal a ciklusinvariánst (mi igaz x4-re minden iteráció elején), és mutassa meg, miért b.lo a helyes feltétel, ha x4 és x5 csoportszám helyett elemszámot jelöl. Mennyiben változna az invariancia, ha az indexelést bájtban végeznénk?

2. feladat (implementációs, középszint — cél: rutinépítés). Írjon mul_acc_neon rutint, amely c[i] += a[i] * b[i]-t számol a NEON MLA (multiply-accumulate) utasítással a fő ciklusban és skaláris MADD-dal a maradékban. Tartsa meg a fő/maradék szerkezetet, és bővítse a korrektségi tesztet úgy, hogy minden n∈[0,17]n \in [0,17]-re mindkét implementációt összevesse. Figyeljen rá, hogy c most bemenet is.

3. feladat (hibakeresés és mérés, középszint — cél: rutinépítés). Adott egy add_neon_buggy változat, amelyben a fő ciklus határa lsr x5, x3, #2 (a lsl kimaradt). Mutassa meg objdump és gdb-multiarch segítségével, hogy n=17n = 17 esetén hány elemet dolgoz fel a fő ciklus, és hányat a maradékciklus. Indokolja a teljes vezérlési út alapján, miért megy át mégis a korrektségi teszt, és mivel lehet a hibát kimutatni. Készítsen ezen felül tételes utasításszámlálást: objdump -d alapján számolja meg, hány utasítás fut le n=16n = 16 elemre a skaláris és a helyes NEON-változatban. Számolja a címképzést, az indexnövelést, az összehasonlítást és az elágazást is, és adja meg külön a ciklusmagra és a teljes útvonalra (belépés, ciklus, maradékág, ret) kapott értéket. Az eredményt dinamikus utasításszám-arányként nevezze meg; a futásidejű gyorsulás külön mérés tárgya (NEON-gyorsulás mérése és korlátai), és QEMU alatt csak illusztratív.

12.4.6 Megoldások

1. megoldás — Segítség. Az index x4 minden iteráció ELEJÉN mit tud már biztosan a feldolgozott tartományról — és mivel az add #4 a cmp ELŐTT fut, mi a kapcsolat x4 és x5 közt a feltétel kiértékelésekor?

1. megoldás — Teljes megoldás. Az invariancia: „az [x0, x0+4*x4) intervallum és a hozzá tartozó b, c szakasz már feldolgozott, x4 mindig néggyel osztható, és x4 <= x5”. Az index növelése a feltételvizsgálat előtt történik, ezért a ciklusmag egyszer már lefutott, amikor a cmp kiértékelődik – ez a do-while minta. A b.lo azért helyes, mert x4 és x5 elemszám, azaz nem negatív mennyiség; b.lt előjeles olvasatot kérne, és 2622^{62} fölötti, irreális hosszakat leszámítva ugyanazt adná, de a szándékot nem fejezi ki. Bájtalapú indexelésnél az invariancia x4 értékére „4 többszöröse, x4 <= 4*x5” lenne, a fő ciklus lépése #16, a maradéké #4, és a címzésből eltűnne az lsl #2 skálázás.

2. megoldás — Segítség. A szorzás-összeadás pontosan ugyanazt a fő/maradék vázat követi, mint az add_i32_neon — csak egy harmadik betöltés (c jelenlegi értéke) és egy MLA/MADD kerül az ADD helyére. Melyik regiszter szerepel KÉTSZER is (forrásként és célként)?

2. megoldás — Teljes megoldás. A fő ciklus lényege a három betöltés és az egyetlen MLA:

// x0 = a, x1 = b, x2 = c, x3 = n
    mov     x4, #0
    lsr     x5, x3, #2
    lsl     x5, x5, #2
    cbz     x5, .Lma_rem
.Lma_main:
    lsl     x9, x4, #2              // bájteltolás = i*4; q-címzéshez külön képezve
    ldr     q0, [x0, x9]
    ldr     q1, [x1, x9]
    ldr     q2, [x2, x9]
    mla     v2.4s, v0.4s, v1.4s     // v2 = v2 + v0*v1, lane-enként
    str     q2, [x2, x9]
    add     x4, x4, #4
    cmp     x4, x5
    b.lo    .Lma_main
.Lma_rem:
    cmp     x4, x3
    b.hs    .Lma_done
.Lma_rloop:
    ldr     w6, [x0, x4, lsl #2]
    ldr     w7, [x1, x4, lsl #2]
    ldr     w8, [x2, x4, lsl #2]
    madd    w8, w6, w7, w8          // w8 = w8 + w6*w7
    str     w8, [x2, x4, lsl #2]
    add     x4, x4, #1
    cmp     x4, x3
    b.lo    .Lma_rloop
.Lma_done:
    ret

Indoklás: az MLA (Advanced SIMD, Vd.4S) és a skaláris MADD ugyanazt a szemantikát adja lane-enként, ezért a maradék ág eredménye bitre azonos a vektoros ágéval. Megjegyzés: ez a 32 bites akkumulátor túlcsordulhat; nagy szorzók esetén szélesítő (widening) változat kell, ami az NEON-gyorsulás mérése és korlátai tárgya. A korrektségi tesztben c-t előre fel kell tölteni (pl. c[i] = 7), hogy a += tényleg érvényesüljön.

3. megoldás — Segítség. Ha a lsl x5, x5, #2 kimarad, x5 CSOPORTSZÁM marad (n/4), de a ciklus x4-et ELEMSZÁMKÉNT, néggyel lépteti — hány iteráció után lesz x4 >= x5, ha x5 csak 4 (nem 16)? És honnan indul utána a maradékciklus?

3. megoldás — Teljes megoldás. A hibás változatban n=17n = 17-re x5 = n/4 = 4, miközben x4 elemenként #4-gyel nő. x4 értékei 0, majd 4; ekkor 4 < 4 hamis, és a fő ciklus kilép. A fő ciklus tehát pontosan 4 elemet (index 0–3) dolgoz fel. A vezérlés ezután a .Lnv_rem ágra kerül, amely az aktuális x4 = 4-től x3 = 17-ig skalárisan futtatja a maradékot: ez 13 elem (index 4–16). Az eredmény minden elemre helyes, ezért a korrektségi teszt átmegy, és első eltérés sincs. A felosztás 4 + 13 a helyes 16 + 1 helyett. Általában x4 a fő ciklus után sosem lépi túl n-t, így a hiba minden nn-re csak teljesítménybeli. GDB-vel a .Lnv_rem címkére tett töréspontnál p $x4, p $x5, p $x3 (4, 4, 17) azonnal megmutatja a dimenzió-keveredést. A hibát egy utasításszám-mérés is kimutatja: n=17n = 17-re a helyes változat 46, a hibás 105 utasítást hajt végre.

Tételes utasításszámlálás n=16n = 16-ra (a helyettesítő eszközlánccal emulátorban, utasításonként megszámolva is ezt kaptuk):

Változat Belépés Ciklusmag × iteráció Maradékág Kilépés Összesen
skaláris cbz, mov = 2 (ldr, ldr, add, str, add, cmp, b.lo) = 7 × 16 = 112 – ret = 1 115
NEON mov, lsr, lsl, cbz = 4 (lsl, ldr q, ldr q, add v, str q, add, cmp, b.lo) = 8 × 4 = 32 cmp, b.hs = 2 ret = 1 39

A ciklusmagok dinamikus utasításszám-aránya 112/32=3,5112 / 32 = 3{,}5, a teljes útvonalé (belépéssel és kilépéssel) 115/39≈2,95115 / 39 \approx 2{,}95. Ez nem gyorsulási plafon. A négy sávból vagy ebből az arányból nem vezethető le ISA-szintű felső korlát, mert az utasítások ideje nem azonos: egy ldr q vagy str q más költségű, mint egy skaláris ldr w, és a futásidőt a memóriasávszélesség, a késleltetések és a mikroarchitektúra párhuzamossága is alakítja. A futásidő egy megnevezett végrehajtási modell vagy mérés tárgya (NEON-gyorsulás mérése és korlátai).

12.4.7 Rövid összefoglaló

A vektorizált tömb-összeadás két fázisra bomlik: fő ciklusra, amely SS elemes lépésekkel, ldr q / add v.Vd.T / str q mintával dolgozik, és maradékciklusra, amely a nmodSn \bmod S elemet skalárisan fejezi be. A fő ciklus határa S⌊n/S⌋S\lfloor n/S \rfloor, elemszám- és bájtalapú indexelés közül tudatosan kell választani; a kettő keverése a legjellemzőbb hiba. A korrektséget független C-orákulummal, minden maradékosztályt lefedő hosszúsorozaton és a tömbhatárok körüli őrszavakkal kell igazolni, mindkét változatra külön. A szorzás-összeadás mintázat ugyanezt a vázat használja MLA/MADD utasításokkal.

Továbbhasznált definíciók: fő ciklus (main loop); maradékciklus (remainder loop); lane-párhuzamos feldolgozás (element-wise map minta).

12.4.8 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.

A helyettesítő eszközlánccal (Clang + musl, statikus build, Unicorn-alapú emulátor) ténylegesen lefuttattuk a teszthajtót a helyes rutinokkal és a táblázat beültetett hibáival; a 3. megoldás utasításszámait emulátorban, utasításonként megszámolva is ellenőriztük (skaláris n=16n=16: 115, NEON n=16n=16: 39, a lsl nélküli NEON n=17n=17: 105 a helyes 46 helyett). A kész ELF GNU_STACK jelzője RW; -z execstack-kel linkelve a közös readelf-ellenőrzés RWE-t jelez és nem nulla állapottal bukik.

Ellenőrzési hiányok, amelyek a fenti ellenőrzés után is ténylegesen fennmaradnak: (1) A qemu-aarch64 verziója és a -static linkelés szükségessége Ubuntu 24.04 LTS csomagolt toolchain mellett nem ellenőrzött ebben a kérésben; a pontos verziót és a -L sysroot-opció szükségességét a hallgató laborjában kell validálni. (2) A fenti utasításszám-arány levezetett érték, nem mért; QEMU user-mode nem ad hardverhű ciklusszámot. (3) Az int32_t előjeles túlcsordulás és a NEON modulo-2322^{32} viselkedés eltérésének gyakorlati kezelése (fordítóflag-ek, sanitizerek) nincs ebben az alfejezetben specifikálva – ez az NEON-gyorsulás mérése és korlátai felé mutat.