10.3 Fordítói optimalizálási szintek hatása a generált kódra

10.3.1 Miért változik meg ugyanannak a C-függvénynek a gépi alakja?

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.

10.3.2 Optimalizálás: a viselkedés marad, a megvalósítás változhat

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 2322^{32} é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.

10.3.3 Kidolgozott példa: összegzés belső segédfüggvénnyel

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/a038

Mentsd 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:

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:

Ez szemantikai ellenőrzés, nem teljesítménymérés.

10.3.4 Fordítás, assemblyelőállítás, linkelés és futtatá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}"
done

Vá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.txt

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

10.3.5 Mit keress az eredményben?

10.3.5.1 Regiszterek a veremhelyek helyett

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.

10.3.5.2 A segédfüggvény beágyazása

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.

10.3.5.3 Holt számítás eltávolítása

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.

10.3.5.4 A ciklus alakja

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

Az 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 x+(x≪1)x+(x\ll1) azonossággal számítja ki a 3*x értéket, modulo 2322^{32}. 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.

10.3.6 Hibák, ellenpróbák és értelmezési korlátok

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

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

10.3.7 Feladatok

  1. (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?

  2. (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.

  3. (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?

10.3.8 Megoldások

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 n(n−1)/2n(n-1)/2, ezért ebben a kis tartományban a várt eredmény:

S(n)=3n(n−1)2+n.S(n)=3\frac{n(n-1)}{2}+n.

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.

  1. A deklaráció kezdete legyen 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.

10.3.9 Rövid összefoglaló

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.

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

Az alábbiak dokumentációs tájékozódási pontok, nem ebben a munkafolyamatban megnyitott, olvasott források:

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.