Egy C-ből hívott számolófüggvény helyes eredményt ad, de a disassemblyben sok veremműveletet látsz. Érdemes ezeket kézzel assemblyre átírni? Először azt kell ellenőrizni, hogy optimalizált kódot vizsgálsz-e. Egy optimalizálás nélkül fordított függvény memóriaforgalmának jelentős része nem az algoritmusból, hanem a fordítóprogram választott kódgenerálási stratégiájából eredhet.
Ebben a laborban ugyanazt a C-forrást fordítjuk le
-O0, -O1, -O2 és -O3
beállítással. A cél nem egy adott GCC-kiadás utasítássorozatának
bemagolása, hanem három összefüggés felismerése:
Profil: I. rész, AArch64, A64 ISA, ARMv8-A alapprofil, little-endian. Az alábbi assemblyregiszterek és utasítások kizárólag ebben a környezetben értendők. Előfeltétel az Assembly függvény hívása C-ből és C hívása assemblyből alfejezet hívási és build-ismerete.
A fordítói optimalizálás (compiler optimization) a program belső reprezentációjának olyan átalakítása, amely megőrzi a C által előírt megfigyelhető viselkedést, miközben például gyorsabb vagy kisebb gépi kódot eredményezhet. Nem követelmény, hogy minden C-változónak külön memóriacím, minden kifejezésnek külön utasítás feleljen meg.
Ez csak a C szerint értelmezett működésre vonatkozik. Az előjeles
egész túlcsordulása például nem definiált viselkedés; az optimalizálás
kikapcsolása sem teszi szabályossá. A labor ezért uint32_t
típusú, előjel nélküli aritmetikát használ: az
eredmények modulo
értendők.
Az optimalizálási szint nem teljesítménygarancia:
| Kapcsoló | Jellemző cél és hatás | Fontos korlát |
|---|---|---|
-O0 |
Kevés optimalizálás; többnyire könnyebben követhető forrásszerkezet | Nem jelenti minden átalakítás teljes hiányát |
-O1 |
Alapvető egyszerűsítések, holt számítások eltávolítása, kevesebb felesleges adatmozgatás | A pontos átalakításkészlet GCC- és célfüggő |
-O2 |
Gyakori kiindulópont üzemi kódhoz; szélesebb optimalizálási készlet | Vektorizálás is megjelenhet, kiadástól és költségmodelltől függően |
-O3 |
További, különösen ciklusokra és kódbeágyazásra irányuló átalakítások | Nagyobb kódot is adhat; nem szükségképpen gyorsabb |
A kapcsolók nem szabványos C-fogalmak, hanem GCC-beállítások. A laborban használt kiadás ténylegesen engedélyezett optimalizálásait külön is le lehet kérdezni.
Munkakönyvtár: ~/arm-labor/a64/a038/ —
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/a038
cd ~/arm-labor/a64/a038Mentsd a következő fájlt lab-kernel.c néven:
/* AArch64/A64, ARMv8-A, little-endian célprofil. */
#include <stddef.h>
#include <stdint.h>
static uint32_t lab_step(uint32_t x)
{
return x * UINT32_C(3) + UINT32_C(1);
}
uint32_t lab_sum(const uint32_t *a, size_t n)
{
uint32_t sum = 0;
for (size_t i = 0; i < n; ++i) {
uint32_t dead = a[i] ^ UINT32_C(0x5a5a5a5a);
(void)dead;
sum += lab_step(a[i]);
}
return sum;
}A függvény szerződése:
n > 0 esetén a legalább n
olvasható uint32_t elemet jelöl;n == 0 esetén nincs memóriaolvasás, ezért nullmutató is
átadható;A dead változó szándékosan felesleges. A
(void)dead kifejezés jelzi a szándékosan fel nem használt
értéket, de nem teszi megfigyelhetővé a számítást, és
nem akadályozza meg annak eltávolítását.
A tesztprogram legyen lab-test.c:
/* AArch64/A64, ARMv8-A, little-endian célprofil. */
#include <inttypes.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
uint32_t lab_sum(const uint32_t *a, size_t n);
int main(void)
{
static const uint32_t zero[] = {0};
static const uint32_t small[] = {1, 2, 3, 4};
static const uint32_t wrap[] = {UINT32_MAX, 0, 1};
static const uint32_t tail[] = {0, 1, 2, 3, 4, 5, 6};
const struct {
const uint32_t *data;
size_t n;
uint32_t expected;
} tests[] = {
{NULL, 0, 0},
{zero, 1, 1},
{small, 4, 34},
{wrap, 3, 3},
{tail, 7, 70}
};
const size_t count = sizeof tests / sizeof tests[0];
for (size_t i = 0; i < count; ++i) {
uint32_t got = lab_sum(tests[i].data, tests[i].n);
if (got != tests[i].expected) {
fprintf(stderr,
"FAIL: teszt=%zu kapott=%" PRIu32
" vart=%" PRIu32 "\n",
i, got, tests[i].expected);
return 1;
}
}
printf("OK: %zu teszt\n", count);
return 0;
}A túlcsordulási teszt várt eredményének levezetése, előjel nélküli olvasatban:
lab_step(UINT32_MAX) értéke modulo
számolva 0xfffffffe;lab_step(0) értéke 1;lab_step(1) értéke 4;3.Ez szemantikai ellenőrzés, nem teljesítménymérés.
A gcc -S megáll az assemblyforrás előállításánál: nem
készít objektumfájlt és nem linkel. Az alábbi munkafolyamat ezért külön
végzi el az assembly összeállítását és a linkelést.
A tesztprogram mindig -O0 beállítással készül; kizárólag
a vizsgált modul optimalizálási szintje változik. Nem használunk
linkeléskori optimalizálást (link-time optimization, LTO), így
a fordító nem látja együtt a tesztadatokat és a függvény
implementációját.
# Host: Ubuntu 24.04 x86_64.
# Target: qemu-user, AArch64/A64, ARMv8-A, little-endian.
# Előfeltétel: cross GCC/binutils, QEMU és statikus cross libc.
set -eu
{
aarch64-linux-gnu-gcc --version
aarch64-linux-gnu-as --version
aarch64-linux-gnu-ld --version
qemu-aarch64 --version
} > lab-versions.txt
aarch64-linux-gnu-gcc \
-std=c11 -Wall -Wextra -O0 -g \
-march=armv8-a -mlittle-endian \
-c lab-test.c -o lab-test.o
for level in 0 1 2 3; do
aarch64-linux-gnu-gcc \
-std=c11 -Wall -Wextra -O"$level" -g \
-march=armv8-a -mlittle-endian \
-fverbose-asm -S lab-kernel.c \
-o "lab-kernel-O${level}.s"
aarch64-linux-gnu-gcc \
-march=armv8-a -mlittle-endian \
-c "lab-kernel-O${level}.s" \
-o "lab-kernel-O${level}.o"
aarch64-linux-gnu-gcc \
-march=armv8-a -mlittle-endian -static \
lab-test.o "lab-kernel-O${level}.o" \
-o "lab-O${level}"
aarch64-linux-gnu-objdump -dr \
"lab-kernel-O${level}.o" \
> "lab-kernel-O${level}.dis"
printf 'O%s: ' "$level"
qemu-aarch64 "./lab-O${level}"
doneVárt, itt nem futtatott kimenet: mind a négy
szintnél O0: OK: 5 teszt, illetve az ennek megfelelő
O1, O2, O3 kezdetű sor. Mindegyik
program várt kilépési állapota 0; eltérő eredménynél a
tesztprogram 1 értékkel lép ki.
A statikus linkeléshez szükséges cross libc hiánya build-környezeti hiba, nem optimalizálási jelenség.
A generált forrás összevetéséhez:
# Target: qemu-user számára generált AArch64/A64 kód vizsgálata.
# A diff 1-es állapota eltérést jelent, nem hibás fordítást.
diff -u lab-kernel-O0.s lab-kernel-O2.s || test "$?" -eq 1
aarch64-linux-gnu-objdump \
-d --disassemble=lab_sum lab-O2
aarch64-linux-gnu-gcc \
-march=armv8-a -mlittle-endian -O2 \
-Q --help=optimizers > lab-options-O2.txtA GNU assembler AArch64-célú .cfi_*, .loc
és .size direktívái metaadatokat írnak le, nem
végrehajtandó A64 utasítások. A fájl sorainak száma ezért nem
utasításszám. Az objdump segít elkülöníteni a tényleges
gépi kódot.
A regiszterallokáció (register allocation) azt rendeli hozzá, hogy a program élő értékei mikor mely fizikai regiszterekben legyenek. Ha egy értéket memóriába kell kitenni, majd később visszatölteni, az regiszter-kimentés (spill) és visszatöltés lehet. Nem minden veremművelet ilyen: argumentummentés, lokális objektum és ABI szerinti regisztermegőrzés is állhat mögötte.
-O0 mellett gyakran külön veremhely tartozik az
indexhez, az összeghez és az argumentumok másolatához. Egy módosítás
ilyenkor A64 LDR, aritmetikai művelet és STR
sorozatként jelenhet meg.
Optimalizálva az index és az összeg végig regiszterben maradhat. Ráadásul az index külön értékként meg is szűnhet: helyette egy mutató és egy hátralévő elemszám vezérelheti a ciklust.
Fontos árnyalat: regiszterallokáció -O0 mellett is
történik. A különbség nem pusztán „jobb regiszterválasztás”, hanem az
allokáció elé kerülő programreprezentáció egyszerűsítése is.
A függvénybeágyazás (function inlining) a hívás helyére illeszti a hívott függvény műveleteit.
Az A64 kódban keresd a lab_step felé irányuló
BL utasítást. Optimalizálva a szorzás és összeadás
közvetlenül a ciklustörzsben jelenhet meg, a belső hívás pedig eltűnhet.
A C inline kulcsszó ehhez nem szükséges; jelenléte sem
jelent általános beágyazási garanciát.
Elméleti, feltételes levezetés: ha minden
feldolgozott elemhez pontosan egy hagyományos segédfüggvény-hívás
tartozik, akkor n elemhez n darab A64
BL és n darab A64 RET végrehajtás
kapcsolódik. Beágyazáskor ez a belső hívási út eltűnik. Ebből nem
következik meghatározott ciklusszám-megtakarítás.
A hatás továbbgyűrűzhet: a külső függvény levélfüggvénnyé válhat,
ezért a belső hívás miatt szükséges A64 X30-mentés is
feleslegessé válhat. A kívülről hívható lab_sum
AAPCS64-szerződése ettől nem változik.
A holt kód eltávolítása (dead code elimination, DCE) megszünteti azokat a számításokat, amelyek eredménye nem befolyásolja a megfigyelhető viselkedést.
A példában a dead előállítása eltávolítható: az értéket
sem vissza nem adjuk, sem más számításban nem használjuk. A vizsgált
érvényes bemeneteken ez nem változtatja meg a program eredményét.
Ne várd azonban, hogy -O0 garantáltan megtartja. Egy
egyszerű felesleges kifejezés már korábbi fordítási lépésekben is
eltűnhet. Az optimalizálási szintek összehasonlításánál
megfigyelést rögzíts, ne előre elvárt különbséget
erőltess.
A volatile hozzáadása nem jó módszer az eredeti program
vizsgálatára: az új, megfigyelendő hozzáférésekkel megváltoztatná a
feladatot.
Az alábbi A64 részlet illusztratív, normalizált skaláris
ciklustörzs, nem a laborban ellenőrzött GCC-kimenet és nem
önálló program. Belépéskor az A64 X0 a következő elemre
mutat, X1 pozitív hátralévő elemszámot tartalmaz,
W2 az előjel nélküli részösszeg.
// A64 ISA, AArch64, ARMv8-A, little-endian.
// Szemléltető részlet; a teljes programot a C-forrásból állítjuk elő.
.Lloop:
ldr W3, [X0], #4
add W3, W3, W3, lsl #1
add W3, W3, #1
add W2, W2, W3
subs X1, X1, #1
b.ne .LloopAz A64 LDR itt betölt egy 32 bites elemet, majd a
mutatót négy bájttal növeli. A post-index #4 eltolás
belefér a mezőbe.
A következő A64 ADD az
azonossággal számítja ki a 3*x értéket, modulo
.
Az itt használt eltolások, konstansok és a közeli B.NE cél
mind kódolhatók (lásd az A64 immediate-korlátok
összefoglalóját).
Más GCC-kimenet összehasonlíthatja a mutatót egy végcímmel, több iterációt egyesíthet, vagy SIMD-feldolgozást választhat. Utóbbit itt csak azonosítjuk; részletesen a Vektorizált összeadás/szorzás: egy skaláris ciklus NEON-átirata tárgyalja.
Használd a tünet → hipotézis → adatgyűjtés → egy változtatás → ellenőrzés munkamenetet.
| Tünet | Hipotézis és adatgyűjtés | Kontrollált ellenpróba |
|---|---|---|
| Sok veremművelet maradt | Más fájlt vagy a -O0 változatot nézed |
Vizsgáld a megfelelő lab-kernel-O2.o
disassemblyjét |
Nincs külön lab_step hívás |
A segédfüggvény beágyazódott | Tiltsd meg csak ennek beágyazását, majd fordíts újra |
-O2 és -O3 azonos |
Ehhez a függvényhez ugyanazt a megoldást választotta a fordító | Ellenőrizd a kapcsolókat; az azonosság önmagában nem hiba |
| Csak optimalizálva hibás az eredmény | Nem definiált C-viselkedés vagy hibás C–assembly szerződés lehet jelen | Ellenőrizd a határokat, típusokat, megőrzendő regisztereket |
| GDB szerint egy változó „optimized out” | Nincs önálló, követhető tárolási helye | Ugyanazt a kódot fordítsd -O0 -g beállítással |
A fordító döntéseihez kiegészítő jelentést kérhetsz:
# Target: qemu-user/AArch64; elemzési build, futtatás nélkül.
aarch64-linux-gnu-gcc \
-std=c11 -O3 -g -march=armv8-a -mlittle-endian \
-fopt-info-optimized=lab-opt-O3.txt \
-S lab-kernel.c -o lab-report-O3.sA jelentés tartalma és részletessége kiadásfüggő; hiányzó bejegyzés nem bizonyítja egy átalakítás hiányát.
-O0 -g tanuláshoz azért kényelmes, mert általában
közvetlenebb a kapcsolat a C-sorok és a gépi műveletek között. A
-g azonban optimalizálással együtt is használható. A GCC
-Og külön hibakeresési kompromisszumot kínál, de most a
négy kijelölt szintet hasonlítjuk össze.
Üzemi kiindulópontnak gyakran -O2 célszerű;
-O3 akkor előnyös, ha a reprezentatív tesztek ezt
igazolják. A kevesebb utasítás vagy kisebb veremkeret önmagában nem
teljesítménymérés. QEMU user-mode alatt nincs garantált, hardverhű
abszolút ciklusszám; az emulált futási időből nem vezethető le egy
fizikai AArch64 processzor gyorsulása.
(magyarázó, alapszint — cél:
megértés-ellenőrzés) Az optimalizált változatban nincs külön
veremhely a sum változónak, és a dead
számítása sem látható. Melyik érték maradt szemantikailag szükséges?
Miért nem bizonyítja a kevesebb veremművelet önmagában a
gyorsulást?
(implementációs, középszint — cél: rutinépítés)
Egészítsd ki a tesztet egy 17 elemű tömbbel, amelyre
a[i] = i. Teszteld minden n hosszúsággal
0 és 17 között. A várt eredményt ne a vizsgált
segédfüggvénnyel számítsd ki.
(hibakeresési, középszint — cél:
megértés-ellenőrzés) A -O3 változatból eltűnt a
lab_step hívása. Tegyél a segédfüggvényre GCC
__attribute__((noinline)) attribútumot. Csak ezt módosítsd,
fordítsd újra a négy változatot, és keresd meg a hívást. Változott-e a
teszteredmény, a veremkezelés vagy a ciklus alakja?
1. megoldás — Segítség. Melyik változó értéke jelenik meg a függvény visszatérési értékében, és melyiké nem jelenik meg sehol a megfigyelhető kimenetben?
1. megoldás — Teljes megoldás. A részösszeg továbbra
is szükséges, csak regiszterben vagy más, vele ekvivalens alakban él. A
dead értéke szükségtelen, ezért a számítása megszüntethető.
A veremműveletek számának csökkenése lehetséges előny, de a végrehajtási
időt adatfüggőségek, elágazások, memóriahozzáférések és a
mikroarchitektúra is befolyásolják.
2. megoldás — Segítség. Számítsd ki a várt összeget
egy zárt képlettel (ne a lab_step-pel), és futtasd végig
minden n-re 0-tól 17-ig egy külön
ciklusban, mielőtt a meglévő tesztlistához csatolnád.
2. megoldás — Teljes megoldás. Az előjel nélküli tömbelemek összege , ezért ebben a kis tartományban a várt eredmény:
Itt n dimenzió nélküli elemszám, S(n)
dimenzió nélküli egész eredmény; n tartománya
0–17, így nincs túlcsordulás. A következő
részlet a meglévő main függvénybe, a sikerüzenet elé
illeszthető:
/* AArch64/A64 célprofil; kiegészítés a lab-test.c main függvényéhez. */
uint32_t sequence[17];
for (size_t i = 0; i < 17; ++i)
sequence[i] = (uint32_t)i;
for (size_t n = 0; n <= 17; ++n) {
uint64_t k = n;
uint32_t expected = k == 0
? 0
: (uint32_t)(3 * k * (k - 1) / 2 + k);
if (lab_sum(sequence, n) != expected)
return 1;
}A korábbi build-lépéssor változatlanul használható. Ellenőrző
értékek: S(0) = 0, S(4) = 22,
S(17) = 425. Minden változat várt kilépési állapota
továbbra is 0.
static __attribute__((noinline)) uint32_t lab_step(uint32_t x).
A várt megfigyelés a belső hívás visszatérése; a konkrét A64
utasítássorozatot az objdump kimenete dönti el. A
visszatérési cím és a híváson át élő értékek megőrzése ismét szükségessé
válhat. A hívás a ciklus további optimalizálását is korlátozhatja. Az
eredménynek változatlannak kell maradnia: az attribútum kódgenerálási
ellenpróba, nem az algoritmus módosítása.Az optimalizálási szint a C-program megengedett megvalósításai közül
segít választani; nem módosíthatja a definiált megfigyelhető
viselkedést. Ugyanazt a forrást külön assemblyfájlokba fordítva
felismerhető a veremforgalom csökkenése, a belső hívások eltűnése és a
ciklus átszervezése. A pontos kód GCC- és célfüggő, ezért a megfigyelést
tesztekkel, disassemblyvel és rögzített build-kapcsolókkal kell
alátámasztani. A -O0 tanulási és hibakeresési előnye nem
üzemi teljesítményelőny; a -O3 pedig nem automatikusan
gyorsabb a -O2 szintnél. A labor szemantikai
összehasonlítás, nem hardveres sebességmérés.
Az alábbiak dokumentációs tájékozódási pontok, nem ebben a munkafolyamatban megnyitott, olvasott források:
inline kapcsolatának korlátai.Ellenőrzési hiányok: a példákat itt nem fordítottuk és nem futtattuk. A laborban rögzíteni kell az Ubuntu 24.04 telepített eszközverzióit, ellenőrizni a statikus cross libc rendelkezésre állását, továbbá megfigyelni a tényleges beágyazási és vektorizálási döntéseket. Minden közölt kimenet várt, minden assemblyrészlet illusztratív; tényleges teljesítményadat nem szerepel.