15.2 Megvalósítás: az assembly modul implementációja

15.2.1 Motiváció

Az előző alfejezetben rögzítettük a lab_sgemm projekt specifikációját: mit kell a modulnak tudnia, milyen szerződés szerint illeszkedik a C hívó programhoz, hogyan mérjük és auditáljuk. Most eljön a pillanat, amikor a papíron rögzített követelményeket A64 assembly utasításokká kell alakítani. Ez az a munkafázis, ahol a könyv addigi technikái egyetlen, összefüggő modulban találkoznak: regiszterkiosztás (Az A64 regiszterkészlet áttekintése), címzési módok (Load/store címzési módok), eltolás (Shift és rotate: LSL/LSR/ASR/ROR gyakorlati mintázatok), AAPCS64 (Az AAPCS64 regiszterszerződés), keretkezelés (Prológus és epilógus: regisztermentés és keretkezelés), NEON (A NEON regiszterkészlet és adatelrendezés) és C-együttműködés (Assembly függvény hívása C-ből és C hívása assemblyből).

A gyakorlati kihívás háromrétegű. Egyrészt helyesnek kell lennie a bit szintjén: a floating-point szorzás-összegzés sorrendje, a mutatóaritmetika bájt- és elemszám-konverziója, a határellenőrzés nem téveszthet. Másrészt illeszkednie kell az AAPCS64-hez, különben a C hívó kódja néma, nehezen lokalizálható hibát szenved. Harmadrészt elég gyorsnak kell lennie ahhoz, hogy a mérési terv elfogadási küszöbét teljesítse. Mindezt úgy, hogy a kód auditálható legyen: minden regiszterhez tartozzon indoklás, minden határellenőrzés mögött legyen levezetett feltétel.

15.2.2 Definíció és szerződés

Ebben az alfejezetben három fogalmat használunk pontosan.

assembly modul (assembly module). Egy .S fordítási egység, amely egy vagy több .global szimbólumot exportál, és amelyet a linker a C fordító objektumfájljával illeszt össze egyetlen futtatható ELF-be. Az assembly modul nem külön program; nincs saját _start belépési pontja, nincs saját main-je. Ő egy szolgáltató, akit a C oldal függvényhívás formájában ér el.

modul-szerződés (module contract). Az a pontos, regiszterszintű megállapodás, amelyet a modul a hívó felé garantál. Az AAPCS64 ezt öt dimenzióra bontja:

  1. argumentum-átvétel — az első nyolc egész/pointer argumentum rendre X0–X7-ben, a lebegőpontosak V0–V7-ben;
  2. visszatérési érték — egész W0-ban (32 bites) vagy X0-ban (64 bites), lebegőpontos S0/D0-ban;
  3. callee-saved regiszterek megőrzése — ha a modul használ X19–X28-at, X29-et vagy X30-at, azok eredeti értékét vissza kell állítania RET előtt; ugyanez igaz V8–V15 alsó 64 bitjére (azaz D8–D15-re);
  4. verem-igazítás — a publikus interfész belépésekor és elhagyásakor SP 16 bájt többszöröse;
  5. mellékhatás — csak azok a memóriaterületek módosulhatnak, amelyeket a szerződés megenged (jelen esetben a C tömb elemei).

határellenőrzés (boundary check). Az a kód, amely a mag (kernel) futása előtt eldönti, hogy a bemenet egyáltalán értelmezhető-e: null mutatók, nulla méret, méret-túlcsordulás. A határellenőrzés nem „védekezés a hívó ellen” — szerződésszintű precondíció-ellenőrzés, amelynek hiányában a C tömb írása meg nem engedett memóriát érintene.

A specifikáció (Projektspecifikáció: feladat, interfész és mérési terv) szerinti interfész:

int lab_sgemm(size_t n,
              const float *restrict A,
              const float *restrict B,
              float *restrict C);

size_t az AArch64 LP64 ABI-n 64 bites, ezért az n X0-ban érkezik. Az A, B, C mutatók rendre X1, X2, X3-ban. A visszatérési érték W0 (32 bites int), értéke LAB_SGEMM_OK (0) vagy LAB_SGEMM_EINVAL (-1, az a057 specifikáció szerint). A restrict kulcsszó C99 óta része a nyelvnek; fordítói szinten jelzi, hogy a három tömb nem fed át, ami a vektorizálhatóságot erősíti, de az assembly oldalon nem keletkezik belőle kód — a szerződést mi tartjuk be.

15.2.3 AAPCS64-illesztés a modulban

Az AAPCS64 regiszterszerződés fejből tudása kulcs a helyes modulhoz. Foglaljuk táblázatba, mit használunk és miért.

regiszter tartomány AAPCS64 szerep a modulban
X0–X3 0–3 argumentum, caller-saved bemenet (n, A, B, C), prologusban azonnal továbbmásoljuk
X4–X17 4–17 caller-saved temp lokális ciklusváltozók, indexek (a modul X2–X6-ot használja)
X18 18 platformregiszter: az AAPCS64 a platformra bízza. Linuxon a felhasználói kód ideiglenes regiszterként használhatja, más platformokon (például Windows, macOS, vagy Androidon a shadow call stack) foglalt nem használjuk, így a modul platformtól függetlenül helyes
X19–X28 19–28 callee-saved a hosszú életű állapot (n, A, B, C, ciklusváltozók)
X29 29 frame pointer nem használjuk (nincs szükség visszafejtésre)
X30 30 link register nem módosítjuk (nincs BL a modulban)
V0–V7 0–7 caller-saved V0: skalár a broadcast; V1/V2: C/B vektorok; V5: nulla-konstans a C_row nullázásához (külön regiszter, nem V0)
V8–V15 8–15 callee-saved (alsó 64 bit) nem használjuk → nincs mentési teher
V16–V31 16–31 caller-saved nem használjuk

Két tervezési döntés következik. Egyrészt az összes hosszú életű értéket X19–X28-ba tesszük, hogy a BL-hívásokon (bár a modulban nincs ilyen) és a NEON utasításokon túl is túléljék a ciklusokat; cserébe a prologusnak öt STP-t és az epilógusnak öt LDP-t kell tartalmaznia. Másrészt szándékosan nem használunk V8–V15-öt: ezek alsó 64 bitje (D8–D15) callee-saved, így használatukhoz további mentés és visszaállítás kellene. Egy S8-ba írás például a V8 felső bitjeit is nullázza, tehát a teljes D8 megváltozik. A V0–V7 közül választott regiszterek (V0, V1, V2, V5) caller-saved-ek, ezért ingyenesek.

A prologus–epilógus pár:

    stp     x19, x20, [sp, #-80]!
    stp     x21, x22, [sp, #16]
    stp     x23, x24, [sp, #32]
    stp     x25, x26, [sp, #48]
    stp     x27, x28, [sp, #64]

Az első STP pre-index címzésmóddal dolgozik: SP -= 80, és az X19, X20 párt az új SP-re írja. A többi STP a frissen kialakított frame belső offsetjeire ment. A 80 bájt 16 bájt többszöröse, ezért a publikus interfész verem-igazítása megmarad. Az epilógus ugyanazokról a helyekről tölt vissza; az utolsó pár a +64 eltoláson van. Csak ezután szabad a 80 bájtos keretet felszabadítani:

    ldp     x19, x20, [sp]
    ldp     x21, x22, [sp, #16]
    ldp     x23, x24, [sp, #32]
    ldp     x25, x26, [sp, #48]
    ldp     x27, x28, [sp, #64]
    add     sp, sp, #80
    ret

X29-et (frame pointer) szándékosan nem állítjuk: az egyszerű, nem rekurzív moduloknál a frame pointer csak felesleges utasításokat és mentést hozna. A RET az X30-ban lévő visszatérési címet használja, amelyet a hívó BL-je automatikusan beírt; mivel a modulban nincs további BL, az X30 érintetlen marad, és RET a C hívóba tér vissza.

15.2.4 Bemenet-validálás és határellenőrzés

A határellenőrzést a prologus után, de a fő hurkok előtt végezzük, egyszer. Sorrend:

  1. n == 0 → üres szorzat, nincs teendő, LAB_SGEMM_OK. Ez azért nem EINVAL, mert a nulla méret C-szemantikailag jól definiált; a null mutatókat viszont ebben az esetben nem büntetjük.
  2. valamelyik mutató null → LAB_SGEMM_EINVAL. A CBZ (Compare and Branch on Zero) A64 utasítás 32/64 bites regiszterre: cbz x20, .Linvalid egyszerre hasonlít és elágaztat.
  3. túlcsordulás — nem az n*n elemszámnak, hanem a tényleges, címzéshez használt bájtméretnek (n*n*4) kell 64 biten elférnie. A umulh x5, x19, x19 az n*n magas 64 bitjét adja vissza (a szorzat formálisan 128 bites); ha ez nem nulla, n*n maga sem fér el 64 biten, és ez már elegendő a LAB_SGEMM_EINVAL-hoz. De ha x5 == 0, ez önmagában nem garantálja, hogy n*n*4 is elfér: n*n lehet 64 biten belüli, mégis 2^62-nél nagyobb érték, és az ilyen n*n már *4-gyel túlcsordulna. Ezért külön is ellenőrizni kell az alsó 64 bites szorzat (mul x6, x19, x19) felső két bitjét: ha ezek közül bármelyik 1, n*n >= 2^62, azaz n*n*4 >= 2^64 — túlcsordulás.

Ez a umulh-alapú ellenőrzés az Adatmozgatás, aritmetika és állapotjelzők óta a bitszintű gondolkodás egyenes alkalmazása: nem elég a MUL alsó 64 bitjét nézni, mert ott az n*n-re vonatkozó túlcsordulás elveszne — de a *4 szorzóra vonatkozó, másik túlcsordulás éppen ennek az alsó 64 bitnek a felső két bitjéből olvasható ki.

    cbz     x19, .Lempty_ok
    cbz     x20, .Linvalid
    cbz     x21, .Linvalid
    cbz     x22, .Linvalid
    umulh   x5, x19, x19           // n*n magas 64 bitje
    cbnz    x5, .Linvalid          // n*n > 2^64-1 -> tulcsordulas
    // n*n alacsony 64 bitje (itt mar tudjuk, hogy ez a teljes ertek)
    mul     x6, x19, x19
    lsr     x6, x6, #62            // a felso 2 bitet hozza le a 0/1. bitre
    cbnz    x6, .Linvalid          // n*n >= 2^62 -> n*n*4 tulcsordulna

A CBZ/CBNZ közvetlen elágazás ±1 MiB hatótávolságon belülre mutat, fix immediate-kódolással; a modul méreténél ez bőven elég. A #LAB_SGEMM_OK az .equ által definiált szimbolikus konstans, amelyet az assembler literal értékkel helyettesít.

15.2.5 Kidolgozott példa — a modul teljes implementációja

Munkakönyvtár: ~/arm-labor/a64/zaro1/ — az alfejezet minden laborfájlja ide kerül, és minden parancs innen futtatandó (a könyvtárrendet a Labor-alapkörnyezet alfejezet írja le). Ez az I. záróprojekt közös könyvtára: mind a négy alfejezete (a057–a060) itt dolgozik, és egymás fájljaira épít.

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

Az alábbi teljes, önálló .S fájl (lab_sgemm.S), a hozzá tartozó fejléc, C hívó program és Makefile. A generátor ezeket nem futtatja; a futtatás a hallgató laborjában, qemu-aarch64 alatt vagy natív AArch64-en történik.

Először a fejléc, közös restrict szerződéssel:

/* lab_sgemm.h */
#ifndef LAB_SGEMM_H
#define LAB_SGEMM_H
#include <stddef.h>
#define LAB_SGEMM_OK     0
#define LAB_SGEMM_EINVAL -1
int lab_sgemm(size_t n,
              const float *restrict A,
              const float *restrict B,
              float *restrict C);
#endif

A modul forrása:

/* lab_sgemm.S  —  A64 ISA, AArch64.  C := A * B  (minden nx n float). */
    .section .text
    .global  lab_sgemm
    .type    lab_sgemm, %function

    .equ     LAB_SGEMM_OK,     0
    .equ     LAB_SGEMM_EINVAL, -1

lab_sgemm:
    .cfi_startproc
    /* Prologus: 80 bájt frame, x19-x28 mentése. CFA = belépési SP = SP + 80. */
    stp     x19, x20, [sp, #-80]!
    .cfi_def_cfa_offset 80
    .cfi_offset 19, -80
    .cfi_offset 20, -72
    stp     x21, x22, [sp, #16]
    .cfi_offset 21, -64
    .cfi_offset 22, -56
    stp     x23, x24, [sp, #32]
    .cfi_offset 23, -48
    .cfi_offset 24, -40
    stp     x25, x26, [sp, #48]
    .cfi_offset 25, -32
    .cfi_offset 26, -24
    stp     x27, x28, [sp, #64]
    .cfi_offset 27, -16
    .cfi_offset 28, -8

    /* Paraméterek átmentése callee-saved regiszterekbe. */
    mov     x19, x0                 // n
    mov     x20, x1                 // A
    mov     x21, x2                 // B
    mov     x22, x3                 // C

    /* Határellenőrzés. */
    cbz     x19, .Lempty_ok
    cbz     x20, .Linvalid
    cbz     x21, .Linvalid
    cbz     x22, .Linvalid
    umulh   x5, x19, x19           // n*n magas 64 bitje
    cbnz    x5, .Linvalid          // n*n > 2^64-1 -> tulcsordulas
    mul     x6, x19, x19           // n*n alacsony 64 bitje
    lsr     x6, x6, #62            // felso 2 bit a 0/1. bitre
    cbnz    x6, .Linvalid          // n*n >= 2^62 -> n*n*4 tulcsordulna

    /* Nulla-konstans a C_row nullázásához. Szándékosan NEM v0: a k-ciklus
       minden iterációban felülírja v0-t az "a" broadcast értékével, ezért
       egy közös regiszteren a nullázás és a broadcast ütközne (a második
       sortól a nullázás a broadcast maradék értékét írná ki nulla helyett). */
    movi    v5.4s, #0

    /* i ciklus: i = 0..n-1 */
    mov     x23, #0
.Li_loop:
    cmp     x23, x19
    b.hs    .Ldone

    mul     x24, x23, x19           // i*n  (elemek)
    lsl     x24, x24, #2            // i*n*4 (bájtok)
    add     x25, x20, x24           // A_row = A + i*n*4
    add     x26, x22, x24           // C_row = C + i*n*4

    /* C_row[i, :] = 0, NEON-nal 4-esével. */
    mov     x2, #0
.Lzero_loop:
    add     x3, x2, #4
    cmp     x3, x19
    b.hi    .Lzero_tail
    lsl     x4, x2, #2              // bájteltolás = j*4; q-címzéshez külön képezve
    str     q5, [x26, x4]
    add     x2, x2, #4
    b       .Lzero_loop
.Lzero_tail:
    cmp     x2, x19
    b.hs    .Lzero_done
    str     wzr, [x26, x2, lsl #2]
    add     x2, x2, #1
    b       .Lzero_tail
.Lzero_done:

    /* k ciklus: k = 0..n-1 */
    mov     x27, #0
.Lk_loop:
    cmp     x27, x19
    b.hs    .Lnext_i

    ldr     s0, [x25, x27, lsl #2]  // a = A_row[k]
    dup     v0.4s, v0.s[0]          // a broadcast 4 sávra

    mul     x28, x27, x19           // k*n
    lsl     x28, x28, #2            // k*n*4
    add     x28, x21, x28           // B_row = B + k*n*4

    /* j ciklus NEON-nal, 4 elemenként. */
    mov     x2, #0
.Lj_loop:
    add     x3, x2, #4
    cmp     x3, x19
    b.hi    .Lj_tail
    lsl     x4, x2, #2              // bájteltolás = j*4; q-címzéshez külön képezve
    ldr     q1, [x26, x4]           // C_row[j..j+3]
    ldr     q2, [x28, x4]           // B_row[j..j+3]
    fmla    v1.4s, v0.4s, v2.4s     // C += a * B
    str     q1, [x26, x4]
    add     x2, x2, #4
    b       .Lj_loop

.Lj_tail:
    cmp     x2, x19
    b.hs    .Lnext_k
    ldr     s1, [x26, x2, lsl #2]
    ldr     s2, [x28, x2, lsl #2]
    fmadd   s1, s0, s2, s1          // skaláris maradék
    str     s1, [x26, x2, lsl #2]
    add     x2, x2, #1
    b       .Lj_tail

.Lnext_k:
    add     x27, x27, #1
    b       .Lk_loop

.Lnext_i:
    add     x23, x23, #1
    b       .Li_loop

.Ldone:
    mov     w0, #LAB_SGEMM_OK
    b       .Lepi

.Linvalid:
    mov     w0, #LAB_SGEMM_EINVAL
    b       .Lepi

.Lempty_ok:
    mov     w0, #LAB_SGEMM_OK

.Lepi:
    ldp     x19, x20, [sp]
    ldp     x21, x22, [sp, #16]
    ldp     x23, x24, [sp, #32]
    ldp     x25, x26, [sp, #48]
    ldp     x27, x28, [sp, #64]
    .cfi_restore 19
    .cfi_restore 20
    .cfi_restore 21
    .cfi_restore 22
    .cfi_restore 23
    .cfi_restore 24
    .cfi_restore 25
    .cfi_restore 26
    .cfi_restore 27
    .cfi_restore 28
    add     sp, sp, #80
    .cfi_def_cfa_offset 0
    ret
    .cfi_endproc

    .size   lab_sgemm, . - lab_sgemm

    .section .note.GNU-stack,"",%progbits   // nem kér végrehajtható vermet

Nézzük lépésről lépésre, mit csinálunk, és melyik korábbi alfejezet technikáját alkalmazzuk.

Prologus és paramétermentés. A STP ... [sp, #-80]! pre-index írás és a többi STP [sp, #imm] címzésmódja a Load/store címzési módok és a Prológus és epilógus: regisztermentés és keretkezelés közvetlen alkalmazása. A MOV Xn, Xm alak MOV-variánsok és immediate-kódolás — nincs immediate, csak regiszter-regiszter másolás.

Címzés és skálázás. Az [X25, X27, lsl #2] címzési mód regiszter + eltolt regiszter, 2-vel shiftelve, azaz X25 + (X27 << 2); ez az Tömbök és indexelt elérés assemblyben mintapéldája: az lsl #2 konvertálja az elemszámot bájtra, mert sizeof(float) == 4. A mul + lsl #2 páros ugyanezt teszi a soronkénti stride-nál. Ez a forma S/W (32 bites, 4 bájtos) hozzáférésnél — mint a fenti ldr s0, [x25, x27, lsl #2] — helyes, mert a shift (#2) pontosan a transzferméretnek (4 bájt) megfelelő skálázás. A Q (128 bites, 16 bájtos) regiszteres betöltésnél/tárolásnál viszont a hardver a regiszter-offset lsl mezőjében csak #0 vagy #4 (log2(16)) kódolást enged meg; lsl #2 ott nem létező kódolás, az assembler visszadobja. Ezért a q-s hozzáféréseknél (C_row/B_row négyesei) a bájteltolást (j*4) külön lsl x4, x2, #2 utasítással képezzük előre, és shift nélkül, [Xn, X4] alakban hivatkozunk rá.

NEON vektorizálás. A movi v5.4s, #0 és dup v0.4s, v0.s[0] az A NEON regiszterkészlet és adatelrendezés tárgyalt lane-kezelése: a dup a forrás regiszter 0. sávját mind a négy sávba másolja. A fmla v1.4s, v0.4s, v2.4s az fused multiply-accumulate vektoros változata, a C += a * B műveletet végzi el egyetlen utasításban négy elemre. Ez a Vektorizált összeadás/szorzás: egy skaláris ciklus NEON-átirata közvetlen továbbvitele. A skalár farok (fmadd s1, s0, s2, s1) az n nem-néggyel-osztható hátralékát dolgozza fel, elkerülve a túlindexelést. Fontos regiszterkezelési szabály: a V0 minden k-iterációban felülíródik az a broadcast értékével (a ldr s0, ... a V0 alsó 32 bitjét írja, és a hardver az írással a felső biteket nullázza, mielőtt a dup szétmásolná), ezért a C_row nullázásához nem szabad V0-t újrafelhasználni egy másik, hosszabb életű konstanshoz — ez az oka, hogy a nulla-konstans külön V5-ben él, amelyet a k-ciklus sosem ír.

Egész-aritmetika. A mul, add, cmp, valamint a cbz/cbnz Feltétel nélküli és feltételes elágazás szerinti feltételes vezérlést ad. Az umulh bitszintű viselkedése az Egész aritmetika: ADD/SUB/MUL/DIV és a kettes komplemens viselkedése hatókörébe tartozik, de konkrét használata (túlcsordulás-detektálás) új itt. Az b.hs feltétel (unsigned higher or same) — >= jelentéssel — a CMP által beállított C flagre épül, és fontos, hogy itt unsigned összehasonlításról van szó, mert X19 (n) size_t.

Caller-saved vs callee-saved. A X2–X4, X6 ideiglenes ciklus- és címváltozókat bátran felülírjuk, mert caller-saved; az X19–X28 callee-saved állapotot a prologus/epilógus védi. A .cfi_* direktívák ezt a keretet írják le a hibakeresőnek: a függvény nem hív mást, ezért nem épít X29/X30 keretrekordot, a CFA-t az SP-hez képest adjuk meg (Prológus és epilógus). Az X30 végig a saját regiszterében marad, ezért nincs hozzá .cfi_offset. Ez levélfüggvénynél szabályos: az LR-t csak akkor kell menteni, ha a függvény felülírja (például BL-lel).

Veremjelző. A fájl végi .note.GNU-stack szakasz azt jelzi a linkernek, hogy a modul nem kér végrehajtható vermet. A korábbi alfejezetek mintáiban is szerepel, ezért a projektmagban is ott a helye. Ez következetességi javítás, nem egy igazolt hiba kijavítása: a direktíva hiányából nem következik automatikusan végrehajtható verem. Hogy a linker mit csinál jelzés nélküli objektummal, az eszközlánc- és verziófüggő (egy AArch64 Binutils 2.40-es próbában a direktíva nélkül is RW lett a verem). A döntő bizonyíték ezért nem a forrás, hanem a kész ELF GNU_STACK szegmense, amit a build ellenőriz (lásd lent). Az S0–S2 és V5 (a V0–V2, V5 alsó 32, illetve teljes 128 bitje) caller-saved, így nem igényel mentést.

A C hívó program, amely referenciát és aktuális modult futtat, összevetve őket:

/* lab_sgemm_main.c */
#include <stdio.h>
#include <stdlib.h>
#include <math.h>
#include "lab_sgemm.h"

static void ref_sgemm(size_t n, const float *A, const float *B, float *C)
{
    for (size_t i = 0; i < n; i++)
        for (size_t j = 0; j < n; j++) {
            double s = 0.0;
            for (size_t k = 0; k < n; k++)
                s += (double)A[i*n + k] * (double)B[k*n + j];
            C[i*n + j] = (float)s;
        }
}

int main(void)
{
    const size_t n = 8;
    float *A = malloc(n*n*sizeof A[0]);
    float *B = malloc(n*n*sizeof B[0]);
    float *C = malloc(n*n*sizeof C[0]);
    float *R = malloc(n*n*sizeof R[0]);
    if (!A || !B || !C || !R) { free(A); free(B); free(C); free(R); return 1; }
    for (size_t i = 0; i < n*n; i++) {
        A[i] = (float)((i * 7) % 5) * 0.25f;
        B[i] = (float)((i * 3) % 7) * 0.125f;
    }
    ref_sgemm(n, A, B, R);
    int rc = lab_sgemm(n, A, B, C);
    if (rc != LAB_SGEMM_OK) {
        printf("rc=%d\n", rc);
        free(A); free(B); free(C); free(R);
        return 1;
    }
    double max_err = 0.0;
    size_t bad = 0;
    for (size_t i = 0; i < n*n; i++) {
        double d = fabs((double)C[i] - (double)R[i]);
        /* NaN-ra a d > max_err és a d <= 1e-4 is hamis: a nem véges elemet
           külön, a NaN-t is elutasító !(d <= tűrés) alakkal számoljuk hibának. */
        if (!isfinite(C[i]) || !(d <= 1e-4)) bad++;
        else if (d > max_err) max_err = d;
    }
    printf("max abs err = %.3e, hibás elemek: %zu\n", max_err, bad);
    free(A); free(B); free(C); free(R);
    return bad == 0 ? 0 : 2;
}

Build és futtatás:

aarch64-linux-gnu-gcc -O2 -c lab_sgemm.S -o lab_sgemm.o
aarch64-linux-gnu-gcc -O2 -c lab_sgemm_main.c -o lab_sgemm_main.o
aarch64-linux-gnu-gcc -static lab_sgemm.o lab_sgemm_main.o -lm -o lab_sgemm
readelf -lW lab_sgemm | awk '$1=="GNU_STACK" {print "GNU_STACK", $7}'   # elvárt: RW
qemu-aarch64 ./lab_sgemm
echo "exit=$?"

vagy make-kel:

# Makefile
CC      = aarch64-linux-gnu-gcc
CFLAGS  = -O2 -Wall -Wextra
LDFLAGS = -static
LDLIBS  = -lm
lab_sgemm: lab_sgemm.o lab_sgemm_main.o
    $(CC) $(LDFLAGS) $^ $(LDLIBS) -o $@
    readelf -lW $@ | awk '$$1=="GNU_STACK" {f=$$7} END {exit f!="RW"}' \
        || { echo "HIBA: a GNU_STACK nem RW"; rm -f $@; exit 1; }
%.o: %.c
    $(CC) $(CFLAGS) -c $< -o $@
%.o: %.S
    $(CC) $(CFLAGS) -c $< -o $@
run: lab_sgemm
    qemu-aarch64 ./$<

Kimenet helyettesítő eszközlánccal mérve (lásd a Források szakaszt): GNU_STACK RW, max abs err = 0.000e+00, hibás elemek: 0, exit=0. A nulla eltérés ennél a bemenetnél nem véletlen: az elemek kis kettedes törtek (a 0,25 és a 0,125 többszörösei), így minden szorzat és részösszeg pontosan ábrázolható float-ban is. Általános bemenetnél nem nulla eltérés várható, és ezt a projektspecifikáció numerikus szerződése kezeli. Ez a gyors próba egyszerűsített abszolút küszöböt (1e-4) használ, de már NaN-biztos: a !(d <= 1e-4) alak és az isfinite miatt egy NaN-os kimenet nem adhat „0 maximális hibát”. (A régi if (d > max_err) minta NaN-ra hamis ágat ad, így a NaN-os elem észrevétlen maradt volna.) A teljes ellenőrzés az a059 tesztkészletének dolga.

15.2.5.1 ABI-ellenőrzés a közös teszthívóval

A numerikus egyezés nem igazolja, hogy a modul visszaadta a hívónak az X19–X28 regisztereket és a belépési SP-t. Ezt a Prológus és epilógus közös teszthívójával ellenőrizzük, mindhárom kilépési úton (normál, üres bemenet, érvénytelen bemenet), mert mindegyik ugyanazon az epilóguson halad át:

cp ~/arm-labor/a64/a029/lab-abi-call.h ~/arm-labor/a64/a029/lab-abi-call.S .
/* lab_sgemm_abi.c — a lab_sgemm ABI-ellenőrzése a közös teszthívóval. */
#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>
#include "lab-abi-call.h"
#include "lab_sgemm.h"

static int lab_case(const char *name, size_t n,
                    const float *A, const float *B, float *C, int want_rc)
{
    uint64_t x0 = 0;
    uint64_t mask = lab_abi_call((lab_abi_fn)lab_sgemm, n,
                                 (uintptr_t)A, (uintptr_t)B, (uintptr_t)C,
                                 0, 0, &x0);
    /* int visszatérési érték: az AAPCS64 szerint csak a W0 (alsó 32 bit)
       értelmes, az X0 felső fele tetszőleges lehet. */
    int rc = (int32_t)(uint32_t)x0;
    int ok = mask == 0 && rc == want_rc;

    printf("%-8s maszk=0x%05" PRIx64 " rc=%d %s\n",
           name, mask, rc, ok ? "OK" : "HIBA");
    return ok;
}

int main(void)
{
    static float A[25], B[25], C[25];
    int ok = 1;

    for (int i = 0; i < 25; i++) {
        A[i] = (float)i;
        B[i] = 1.0f;
    }
    ok &= lab_case("n=5", 5, A, B, C, LAB_SGEMM_OK);       /* NEON + skalár farok */
    ok &= lab_case("n=0", 0, A, B, C, LAB_SGEMM_OK);       /* üres bemenet */
    ok &= lab_case("A=NULL", 5, NULL, B, C, LAB_SGEMM_EINVAL);
    ok &= C[0] == 10.0f && C[24] == 110.0f;                /* sor-összegek */
    return ok ? 0 : 1;
}
# Target: AArch64 Linux, qemu-user.
aarch64-linux-gnu-gcc -std=c17 -O2 -Wall -Wextra -static \
    lab_sgemm_abi.c lab_sgemm.S lab-abi-call.S -o lab_sgemm_abi
qemu-aarch64 ./lab_sgemm_abi
echo "exit=$?"

Várt kimenet:

n=5      maszk=0x00000 rc=0 OK
n=0      maszk=0x00000 rc=0 OK
A=NULL   maszk=0x00000 rc=-1 OK
exit=0

Két részlet érdemel figyelmet. A teszthívó a lab_sgemm X0-ját 64 bitesen adja vissza, a függvény viszont int-et ad: az AAPCS64 szerint ilyenkor csak a W0 értelmes, ezért a teszt az alsó 32 bitet veszi. Az n = 5 eset a NEON-ciklust és a skalár farkat is bejárja, így mindkét kódút a mintákkal feltöltött regiszterek mellett fut.

15.2.6 Hibák és ellenpróbák

Néhány tipikus hiba a megvalósítás során, tünettel, diagnózissal és ellenpróbával.

Tünet 1: a C hívó egyik változója a lab_sgemm hívás után megváltozik, látszólag véletlenszerűen. Hipotézis: hiányos callee-saved mentés — valamelyik X19–X28 regisztert felülírtuk, és nem mentettük. Adatgyűjtés: objdump -d lab_sgemm.o | grep -E 'stp|ldp' mutatja a prologust; a gdb-multiarch break lab_sgemm után info registers x19 x20 ... hívás előtt és után. Egy változtatás: adjuk hozzá a hiányzó STP/LDP párt. Ellenőrzés: ismételt futtatás; a bug megszűnik.

Tünet 2: EINVAL olyankor, amikor a tesztek null méretet adtak n==0-val. Hipotézis: a cbz x19, .Lempty_ok előtt a null pointer ellenőrzés fut. Ellenőrzés: sorrend a forrásban. A megoldás: a n==0 eset legyen első.

Tünet 3: a modul néha néma, hibás eredményt ad, de más inputon jó. Hipotézis: n nem osztható 4-gyel, és a NEON farok hiányzik vagy hibás. Ellenőrzés: futtassuk n = 3, n = 5, n = 7 értékkel; ha csak ezek hibásak, a .Lj_tail (skalár farok) a hunyó. Ellenpróba: cseréljük le a farok ciklust skalárra (fmadd s1,…), és nézzük meg, eltűnik-e a hiba.

Tünet 4: a qemu-aarch64 ./lab_sgemm „cannot execute binary file”-t ír. Hipotézis: a linkelés nem cross-toolchainnel történt (natív gcc külső x86 ELF-et ad). Ellenőrzés: file lab_sgemm mutatja az architektúrát; helyes esetben ELF 64-bit LSB executable, ARM aarch64. Egy változtatás: aarch64-linux-gnu-gcc használata.

Tünet 5: véletlenszerű SIGBUS a NEON store-oknál. Hipotézis: 16 bájtos igazítás megsérült — a hívó float* mutató nem 16-bájt-igazított. Adatgyűjtés: printf("%p\n", (void*)C) a C oldalon; ha p mod 16 != 0, a STR Q1 4-bájt-igazításon is működik A64-en, de a teljesítmény zuhan. Ellenőrzés: posix_memalign-nel 16-bájt-igazított puffert adjunk.

15.2.7 Feladatok

1. (magyarázó, középszint — cél: megértés-ellenőrzés) Magyarázd el, miért nem elég önmagában a umulh-alapú ellenőrzés a túlcsordulás kizárásához, és miért kell mellé a mul eredményének felső két bitjét is megnézni. Add meg azt a legnagyobb size_t n értéket, amelyre a modul ellenőrzése még átmegy (LAB_SGEMM_OK-t adna a határellenőrzésen), és indokold, miért pontosan ez a határ.

2. (implementációs, elmélyült — cél: fejezetek összekapcsolása: Mikrobenchmark készítése és eredmények értelmezése) Bővítsd a .Lj_loop-ot úgy, hogy egyszerre 8 float-ot dolgozzon fel egy iterációban: két q regisztert használva (q1, q3 bemenet, q0 a broadcast a). Az n páratlan maradékára a skalár farok maradjon. Mérd meg a gyorsulást a medián-alapú mérési tervvel (vö. Mikrobenchmark készítése és eredmények értelmezése).

3. (hibakeresési/mérési, középszint — cél: rutinépítés) A Makefile-ban fordítsd -O0-val a lab_sgemm.S-t (ez az as szintjén nem változtat), és hasonlítsd össze a lab_sgemm_main.c-t -O0, -O2, -O3 szinteken. Az assembly modul időzítését clock_gettime(CLOCK_MONOTONIC, …) köré csomagolva mérd. Melyik szinten és miért tapasztalsz eltérést? Add meg a gyökér-okot (a C oldal vektorizálása vs a modul fix kódja).

15.2.8 Megoldások

1. megoldás — Segítség. A umulh csak azt dönti el, hogy n*n fér-e 64 biten — de a modul nem n*n ELEMET, hanem n*n*4 BÁJTOT címez. Van-e olyan n tartomány, ahol n*n még belefér, de a néggyel szorzott érték már nem?

1. megoldás — Teljes megoldás. A umulh x5, x19, x19 önmagában csak azt dönti el, hogy n*n elfér-e 64 biten (x5 == 0 pontosan akkor, ha n ≤ 0xFFFFFFFF, azaz 2³²−1). Ez nem elég: a modul nem n*n-et, hanem n*n*4 bájtot címez. Ha n a 2³¹ és 2³²−1 közötti tartományban van, n*n még befér 64 biten (x5 == 0), de n*n*4 már nem — ez pontosan az a sáv, ahol a umulh-only ellenőrzés hibásan átengedné a bemenetet, és a modul a C tömbön kívülre írna. Ezért kell a mul x6, x19, x19 eredményének (az n*n alsó 64 bitjének) felső két bitjét is megnézni: ha ezek közül bármelyik 1, n*n ≥ 2⁶², azaz n*n*4 ≥ 2⁶⁴ — túlcsordulás, LAB_SGEMM_EINVAL. A legnagyobb elfogadott n az, amelyre n*n < 2⁶², azaz n < 2³¹ — a legnagyobb egész érték n = 2³¹−1 = 2 147 483 647. Ennél az n-nél n*n = 2⁶²−2³²+1, ami *4-gyel 2⁶⁴−2³⁴+4 bájt: éppen 64 biten belül marad, tehát ez a tényleges, pontos felső korlát — nem a (2³²−1)² * 4 ≈ 2⁶⁶ bájt, amit a puszta umulh-ellenőrzés hibásan megengedne.

2. megoldás — Segítség. A négyes változat egyetlen fmla lánca függőséget hoz létre az iterációk között; mi történik, ha KÉT független fmla-láncot futtatsz párhuzamosan két q-regiszterpáron?

2. megoldás — Teljes megoldás. A 8-széles belső ciklus lényege:

.Lj_loop8:
    add     x3, x2, #8
    cmp     x3, x19
    b.hi    .Lj_loop4
    lsl     x4, x2, #2              // bájteltolás = j*4
    ldr     q1, [x26, x4]           // C_row[j..j+3]
    ldr     q2, [x28, x4]           // B_row[j..j+3]
    add     x6, x4, #16             // bájteltolás = (j+4)*4 = j*4+16
    ldr     q3, [x26, x6]           // C_row[j+4..j+7]
    ldr     q4, [x28, x6]           // B_row[j+4..j+7]
    fmla    v1.4s, v0.4s, v2.4s
    fmla    v3.4s, v0.4s, v4.4s
    str     q1, [x26, x4]
    str     q3, [x26, x6]
    add     x2, x2, #8
    b       .Lj_loop8

A két fmla független regiszterláncon fut, ami jobb utasításszintű párhuzamosságot (ILP) enged, mint a négyes változat. Mérési terv: medián és MAD rögzítése mindkét változatra, n=256-ra, cache-meleg futtatásokkal. Az elvárt gyorsulás levezetett: ha a szűk keresztmetszet a memory-bound viselkedés, a gyorsulás elmarad; ha a compute-bound tartományban vagyunk, ~1.5–1.9× között várható. Az érték QEMU alatt illusztratív, nem hardverhű abszolút mérés.

3. megoldás — Segítség. A referencia (ref_sgemm) és a modul közül melyiket fordítja a GCC változó szinten, és melyik marad bitre azonos minden -O szint mellett, mert már kézzel vektorizált assembly?

3. megoldás — Teljes megoldás. A -O0-n a C oldal nem vektorizálja a ref_sgemm-et, így az ~10× lassabb, mint -O2/-O3 esetén; a modul assembly kódja viszont bitre azonos minden szinten, mert már kézzel vektorizált. Az összehasonlítás ezért a referencia és a modul közti gyorsulást adja: -O0-n a látszólagos gyorsulás nagy, de ez a referencia gyengesége, nem a modul ereje. A tanulság: a teljesítménymérést mindig az optimalizált referenciához kell hasonlítani, és rögzíteni kell a fordítói szintet. A mérés CLOCK_MONOTONIC-kal mediánt és MAD-ot rögzítsen, legalább tíz futtatásból.

15.2.9 Rövid összefoglaló

A lab_sgemm modul az I. rész technikáit egyetlen AAPCS64-konform assembly függvényben egyesíti: X0–X3 argumentum, W0 visszatérés, X19–X28 mentése STP/LDP párokkal, SP 16 bájtos igazítása. A határellenőrzés CBZ-vel végzi a null-tesztet, umulh+mul-lal a túlcsordulás-tesztet (a tényleges bájtméretre, nem csak az elemszámra). A kernel ikj sorrendben halad, MOVI/DUP/FMLA NEON-utasításokkal vektorizál, skalár farokkal kezeli az n nem-többszörös esetét. A build aarch64-linux-gnu-gcc-vel, qemu-aarch64-gyel reprodukálható.

Továbbhasznált definíciók: - modul-szerződés: AAPCS64 argumentum-, visszatérési, callee-saved és stack-igazítási megállapodás. - határellenőrzés: CBZ a null-pointerekhez, umulh+mul a bájtméret-túlcsordulás teljes (nem csak elemszám-) ellenőrzéséhez, a mag előtt. - fused multiply-accumulate (FMLA/FMADD): egy utasításban d = a*b + c, NEON sávonként vagy skalárisan.

15.2.10 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 ténylegesen fennmaradnak: (0) a lab_sgemm_main és a lab_sgemm_abi kimenetét, a kész ELF GNU_STACK RW jelzőjét, valamint a lab_sgemm CFI-táblázatát helyettesítő eszközlánccal mértük — zig cc (Clang, musl, aarch64-linux-musl), Unicorn-alapú AArch64 user-mode emulátorban —, nem a könyv GNU GCC/qemu-aarch64 referenciakörnyezetében; (1) a umulh+mul-alapú védelem pontos felső korlátját n = 2³¹−1-re (n*n*4 = 2⁶⁴−2³⁴+4 bájt) vezettük le, de a gyakorlati malloc felső korlát ettől jóval kisebb — a valós host memóriahatárok ellenőrzése nem része e szakasznak. (2) A qemu-aarch64 NEON-teljesítménye és a natív AArch64 hardver viselkedése eltérhet; a szakaszban közölt gyorsulási számok levezetett, illusztratív értékek, nem mért hardveradatok. (3) A restrict tényleges hatása a hívó C-fordító vektorizálására az adott GCC-verziótól függ; az Ubuntu 24.04 csomagolt GCC-verziója ellenőrzést igényel a hallgató laborjában.