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.
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:
X0–X7-ben, a lebegőpontosak
V0–V7-ben;W0-ban (32
bites) vagy X0-ban (64 bites), lebegőpontos
S0/D0-ban;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);SP 16 bájt többszöröse;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.
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.
A határellenőrzést a prologus után, de a fő hurkok előtt végezzük, egyszer. Sorrend:
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.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.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.
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/zaro1Az 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);
#endifA 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.
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.
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.
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).
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.
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.
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.
UMULH,
FMLA, DUP, MOVI,
STP/LDP pre/post-index szemantika, NZCV és
b.hs elsődleges forrása. Ellenőrizve: a
link él; a fejezetszám és a kiadásbetű az Arm-oldalak közös korlátja
miatt nem ellenőrzött.restrict,
-O2/-O3 viselkedés, cross-build lépések.
Tartalmilag ellenőrizve: a legújabb listázott kiadás
GCC 16.2.as kézikönyv — .section,
.global, .type, .size,
.equ, .S előfeldolgozás. Tartalmilag
ellenőrizve: a jelenlegi kiadás binutils 2.47.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.