Skip to content
 

Čakacia slučka alebo boj s optimalizáciou

Čakacia slučka je konštrukcia, ktorá nám do programu umožní veľmi jednoduchým spôsobom vložiť oneskorenie. Nie je to veľmi optimálne riešenie – použitie časovača má nesporne mnohé výhody – ale pre svoju jednoduchosť je to riešenie často používané. Ale čakacia slučka má jednu nevýhodu – kompilátor si pri optimalizácii často myslí. že čakacia slučka je zbytočný kus kódu a z výsledného kódu ju vyhodí. Čo s tým?

Riešením je presvedčiť kompilátor, aby to nerobil. Ukážeme si niekoľko spôsobov – niektoré sú viac-menej prenositeľné, iné sú viazané na kompilátory GCC.

Pozrime sa na jednoduchú čakaciu slučku:

void delay(int n)
{
  int i;

  for (i=0; i<n ; i++)
    ;
}

Keď funkciu skompilujeme s vypnutou optimalizáciou

arm-none-eabi-gcc -O0 -S delay.c

dostaneme nasledujúci kód. Keď odhliadneme od množstva zbytočnej réžie, v kóde nájdeme dôležitú inštrukciu: postupnú inkrementáciu premennej.

delay:
        str     fp, [sp, #-4]!
        add     fp, sp, #0
        sub     sp, sp, #20
        str     r0, [fp, #-16]
        mov     r3, #0
        str     r3, [fp, #-8]
        b       .L2
.L3:
        ldr     r3, [fp, #-8]
        add     r3, r3, #1
        str     r3, [fp, #-8]
.L2:
        ldr     r2, [fp, #-8]
        ldr     r3, [fp, #-16]
        cmp     r2, r3
        blt     .L3
        nop
        add     sp, fp, #0
        ldr     fp, [sp], #4
        bx      lr

Skúsme teraz funkciu skompilovať so zapnutou optimalizáciou:

arm-none-eabi-gcc -Os -S delay.c

Výsledný kód sa značne zjednodušil, avšak čakacou slučkou sa nazývať nedá.

delay:
        bx      lr

Lokálne vypnutie optimalizácie

Jednou z brutálnych metód je vypnutie optimalizácie. GCC poskytuje možnosť vypnúť optimalizáciu pre jednotlivé funkcie. Optimalizáciu je možné vypnúť iba pre celú funkciu, nie je možné ponechať neoptimalizovaný ľubovoľný blok kódu. Prvou variantou je použitie atribútov funkcie:

void __attribute__((optimize("O0"))) delay(int n)
{
  int i;

  for (i=0; i<n ; i++)
    ;
}

Druhou možnosťou je použitie direktívy #pragma GCC.

#pragma GCC push_options
#pragma GCC optimize ("O0")
void delay(int n)
{
  int i;

  for (i=0; i<n ; i++)
    ;
}
#pragma GCC pop_options

V obidvoch prípadoch je výsledná kód rovnaký ako v prvom príklade, kde sme optimalizáciu vypli parametrom kompilátora.

Použitie volatile

Bežnou a obľúbenou metódou ako zabrániť „vyoptimalizovaniu“ čakacej slučky je deklarovať premennú cyklu ako volatile. Táto metóda je viac-menej prenositeľná medzi platformami s viac-menej podobným výsledkom.

void delay(int n)
{
  volatile int i;

  for (i=0; i<n ; i++)
    ;
}

Kód ktorý dostaneme po skompilovaní opäť obsahuje to podstatné: inkrementálme zvyšovanie premennej cyklu.

delay:
        mov     r3, #0
        sub     sp, sp, #8
.L5:
        str     r3, [sp, #4]
        ldr     r3, [sp, #4]
        cmp     r3, r0
        blt     .L3
        add     sp, sp, #8
        bx      lr
.L3:
        ldr     r3, [sp, #4]
        add     r3, r3, #1
        b       .L5

Prvotným účelom modifikátora volatile je informovať kompilátor, že hodnota premennej sa môže meniť aj mimo kontrolu a optimalizátor by to mal vziať do úvahy – napríklad by mal hodnotu premennej vždy pred použitím z pamäte načítať a zmenenú hodnotu zapísať naspäť. Tieto opakované zápisy a čítania vidíme na riadkoch 5 a 6.

Druhým účelom použitia volatile je informovať kompilátor, že prístup k premennej môže mať vedľajší efekt – napríklad ak je premenná asociovaná so nejakým periférnym registrom. Práve tento vedľajší efekt spôsobí, že optimalizátor zachová všetky operácie (a ich poradie) s touto premennou a čakaciu slučku zachová.

Použitie volatile nemáme veľmi radi. Hlavným dôvodom je, že používanie volatile sa stalo cargo kultom. Nefunguje program ako má? Skús volatile. Prestane program fungovať po zapnutí optimalizácie? volatile to vyrieši. Ono to tak aj často býva – chyba v programe sa zamaskuje a cargo kult sa veselo šíri. No a my ho nechceme podporovať.

Použitie bariér

Bariérou rozumieme bod alebo konštrukciu v programe, ktoré optimalizátoru stanovuje bod, pred ktorým musia byť všetky predchádzajuce operácie daného typu ukončené a žiadne nasledujúce operácie nie sú začaté.

Bariérou, ktorá ovplyvňuje poradie operácií v C je napríklad sekvenčný bod. Sekvenčný bod je miesto v programe, ktoré pri optimalizácii nemôže byť premiestnené pred predchádzajúci sekvenčný bod alebo za nasledujúci sekvenčný bod. Môže to byť napríklad logická operácia (preto je výraz (s && s[0]) bezpečný) alebo volanie funkcie.

Vedieť o sekvenčných bodoch nie je na zahodenie, avšak na kontrolu optimalizácie ich využiť nevieme. Sekvenčné body hovoria iba o poradí operácií, nehovoria o tom, ako sa má zachovať optimalizátor. Akýkoľvek kus kódu môže byť odstránený, pokiaľ poradie zvyšných sekvenčných bodov ostane zachované.

Výnimkou je kód, ktorý má vedľajší efekt, ktorý je mimo kontrolu kompilátora. V tomto prípade kompilátor musí zachovať všetky operácie s vedľajším efektom a aj ich poradie. Prvou variantou je už spomínané použitie volatile – všetky operácie ostanú zachované, aby ostal zachovaný prípadný vedľajší efekt.

Druhou variantou je volanie funkcie s neznámym chovaním. Ak do tela čakacej slučky vložíme volanie takejto funkcie, čakacia slučka ostane zachovaná.


extern void dummy();

void delay(int n)
{
  int i;

  for (i=0; i<n ; i++)
    dummy ();
}

A výsledný kód:

delay:
        @ Function supports interworking.
        @ args = 0, pretend = 0, frame = 0
        @ frame_needed = 0, uses_anonymous_args = 0
        push    {r4, r5, r6, lr}
        mov     r5, r0
        mov     r4, #0
.L2:
        cmp     r4, r5
        blt     .L3
        pop     {r4, r5, r6, lr}
        bx      lr
.L3:
        bl      dummy
        add     r4, r4, #1
        b       .L2

Dôležité je, aby táto funkcia bola definovaná v inej kompilačnej jednotke (zdrojovom súbore). V opačnom prípade kompilátor môže (a GCC to aj urobí) takúto funkciu analyzovať a jej volanie eliminovať, čo má za následok že bariéra v cykle bude odstránená a následne bude odstránený aj cyklus.

Ak volanie externej funkcie nahradíme funkciou definovanou v tej istej kompilačnej jednotke (zdrojovom súbore), napríklad takto:

void dummy() {}

optimalizátor neoklameme a výsledkom bude kód, z ktorého čakacia slučka zmizne:

dummy:
        bx      lr
delay:
        bx      lr

Ak z volanej funkcie urobíme funkciu lokálnu, optimalizátor vyhodí aj samotnú funkciu dummy.

static void dummy() {}
delay:
        bx      lr

GCC nám ponúka jednoduchšiu variantu, ako vsunúť sekvenčný bod, ktorý kompilátor nedokáže v rámci optimalizácie odstrániť:

void delay(int n)
{
  int i;

  for (i=0; i<n ; i++)
    asm volatile ("":::);
}

Direktíva asm volatile hovorí, že vkladáme kúsok kódu v assembleri (vôbec nevadí, že je prázdny), ktorý má nejaký vedľajší efekt. Táto informácia stačí, aby čakacia slučka ostala zachovaná:

delay:
        mov     r3, #0
.L2:
        cmp     r3, r0
        blt     .L3
        bx      lr
.L3:
        add     r3, r3, #1
        b       .L2

Výhodou obidvoch riešení v porovnaní s použitím volatile môže byť eliminácia operácií s RAM (premenná cyklu ostáva v registri). Výsledkom je rýchlejšia slučka (jemnejšíe časovanie), či odľahčenie vnútorných zberníc, avšak praktický dopad tažko odhadnúť.

Keď už hovoríme o bariérach, spomeňme ešte pamäťovú bariéru. Účelom pamäťovej bariéry je zaistiť, aby všetky pamäťové operácie pred bariérou boli ukončené.

Vezmime si jednoduchý kód. Ponechajme stranou praktickosť tohoto kódu. Premenná x môže byť napríklad periférny register, kde sa samotným zápisom hodnoty spúšťa nejaká operácia.

extern int x;

void test()
{
        x = 1;
        x = 2;
        x = 3;
        x = 4;
}

Po skompilovaní bez optimalizácie dostaneme očakávaný kód so štyrmi zápismi do pamäte.

        ldr     r3, .L2
        mov     r2, #1
        str     r2, [r3]
        ldr     r3, .L2
        mov     r2, #2
        str     r2, [r3]
        ldr     r3, .L2
        mov     r2, #3
        str     r2, [r3]
        ldr     r3, .L2
        mov     r2, #4
        str     r2, [r3]

Ak funkciu skompilujeme so zapnutou optimalizáciou, ostane nám iba posledná operáciam keďže prvé tri boli vyhodnotené ako nadbytočné.

        mov     r2, #4
        ldr     r3, .L2
        str     r2, [r3]

Najjednoduchším riešením je – ako ináč – použitie volatile:

extern volatile int x;

void test()
{
        x = 1;
        x = 2;
        x = 3;
        x = 4;
}

volatile zabezpečí, že každá operácia s touto premennou bude „dotiahnutá do konca“ a všetky štyri zápisy ostanú zachované. No použitie volatile znamená, že každá operácia s premennou opätovne načíta a zapisuje obsah pamäte aj tam, kde to nie je potrebné.

        mov     r2, #1
        ldr     r3, .L2
        str     r2, [r3]
        mov     r2, #2
        str     r2, [r3]
        mov     r2, #3
        str     r2, [r3]
        mov     r2, #4
        str     r2, [r3]

Použitie volatile je obvyklý spôsob, ako sú v hlavičkových súboroch ošetrené všetky periférne registre. Tým je zabezpečené, že práca s perifériami nebude zasiahnutá optimalizáciou a operácie ostanú zachované čo do počtu aj poradia.

Skúsme dostať optimalizáciu pod kontrolu s použitím pamäťovej bariéry – príklad je aplikovateľný v GCC:

extern int x;

void test()
{
        x = 1;
        x = 2;
        asm ("":::"memory");
        x = 3;
        x = 4;
}

Ako pamäťová bariéra opäť slúži kúsok kódu v assembleri; neobsahuje žiadnu inštrukciu, avšak deklaruje vedľajší efekt na pamäť. Práve táto deklarácia neurčitého vedľajšieho efektu na pamäť robí z tejto inštrukciu pamäťovú bariéru.

Výsledný kód vyzerá nasledovne. Okrem posledného priradenia obsahuje aj posledné priradenie pred pamäťovou bariérou. Zvyšné dve operácie boli vyhodené ako zbytočné.

        mov     r2, #2
        ldr     r3, .L2
        str     r2, [r3]
        mov     r2, #4
        str     r2, [r3]

A to je to, čo sme chceli dosiahnuť.

Print Friendly, PDF & Email
5 930 zobrazení