Umíme to s Delphi: 116. díl – jak opravdu Delphi optimalizují?

V minulém článku jsme se začali zabývat optimalizátorem v Delphi a tím, zda a jak dokáže z našich neefektivních slepenců kódu vytvořit bleskově svištící aplikaci. Protože tato otázka vyvolala v diskusi několik nejasností, je dnešní článek věnován podrobnému popisu práce optimalizátoru v Delphi. Na příkladech a praktických ukázkách si předvedeme, co optimalizátor dělá a co nikoliv.

V dnešním článku navážeme na problematiku, kterou jsme načali před týdnem. Připomeňme, že obsahem předchozího článku byl nejprve popis integrovaného nástroje IDE Delphi pro správu projektu (případně více projektů v rámci jedné projektové skupiny): Project Manager. Ve druhé části článku jsme se pak zabývali možnostmi nastavení překladače Delphi. Navzdory mému původnímu plánu jsme se v tomto popisu nedostali příliš daleko – začali jsme první skupinou přepínačů a tamtéž jsme také skončili, když jsme podrobně rozebírali vlastnosti a rysy optimalizace.

Pro jistotu také ještě zopakujme, že zapínání a vypínání všemožných přepínačů týkající se překladu provádíme v dialogu, který vyvoláme z hlavní nabídky Delphi volbami Project – Options. V otevřeném dialogu se pak zaměříme na záložku nazvanou Compiler. Ta obsahuje několik skupin přepínačů umožňujících nastavovat jednotlivé vlastnosti překladu a tím modifikovat výsledný vzhled vytvořeného spustitelného souboru (případně dynamicky linkované knihovny).

Zmíněný dialog si můžete připomenout na následujícím obrázku:

Klepněte pro větší obrázek

V minulém článku jsme se zabývali především první skupinou přepínačů, která je označená titulkem Code generation. Řekli jsme si, že pokud zatrhneme první zaškrtávací pole (Optimization), bude překladače při generování výsledného souboru provádět všemožné optimalizace a na řadě praktických ukázek jsme si předvedli jejich praktický dopad. Optimalizace prováděné překladačem se týkají především (avšak nikoliv pouze) práce s proměnnými:vágně bychom mohli tuto skutečnost vyjádřit asi tak, že překladač drží hodnoty jednotlivých proměnných pouze v těch časových intervalech, v nichž existuje nebezpečí, že by mohly být čteny. V ostatních případech hodnoty proměnných neřeší a tím dosahuje poměrně významné úspory strojových prostředků.

Kromě práce s proměnnými ale překladač žádné další fragmenty zdrojového kódu nijak příliš neoptimalizuje, i když to tak na první pohled někdy může vypadat. Optimalizátor není žádný zázračný nástroj, který neefektivní slepence kódu „přepíše“ do úžasné svištící aplikace. Protože práce optimalizátoru se zdá být atraktivním tématem, rozhodl jsem se k ní ještě jednou vrátit.

Impulsem k tomuto rozhodnutí byla také diskuse pod minulým článkem, v níž se objevilo několik zajímavých příspěvků (za něž jejich autorům děkuji); dovolil bych si některé ocitovat a částečně rozebrat; snad se na mě jejich autoři nebudou příliš zlobit.

Připomeňme, že popis optimalizace se v zásadě točil okolo následujícího jednoduchého cyklu for:

  for x:=0 to 250 do begin
    y := x * 2;
    z := x;
  end;
  ShowMessage(`Hotovo, z = ` + IntToStr(z));

Čtenář Birkof napsal zajímavou tezi (cituji):

Já být optimalizátorem v Delphi, tak to udělám takhle: nahradím algoritmus

  for x:=0 to 25000000 do begin
    y := x * 2;
    z := x;
  end;

tímto:

begin
  y := 25000000 * 2;
  z := 25000000;
end;

Pokud by tam někdo dal breakpoint, tak bych to trochu upravil jen pro tu chvíli vyvolání breakpointu. (konec citace)

Na to reagoval pan Jan Fiala takto (cituji):

On to v podstatě takto optimalizátor udělá. Zjistí, že FOR je tam k ničemu, tak jej vyhodí. Proto ty rozdíly v časech s a bez optimalizace. (konec citace)

Další příspěvek, který napsal Vít, však nesouhlasil (cituji):

To těžko, to byste nenaměřil 0.419 s ale 0,0000x. (konec citace)

Jaká je tedy skutečnost? Přiznám se, že do hlubin překladače Delphi nevidím, nicméně k zajímavým a dobře vypovídajícím výsledkům se lze dobrat po prozkoumání výsledného spustitelného souboru (přesněji řečeno jeho assemblerových instrukcí).

Nejprve si tedy dovolím přednést své hypotézy, a následně se je pokusím potvrdit (nebo vyvrátit:)) analýzou instrukcí, do nichž se zmíněný cyklus for přeloží.

Jak tedy vlastně pracuje optimalizátor?

Hlavní náplní práce, kterou optimalizátor provádí, je skutečně práce s proměnnými a optimalizace na úrovni proměnných. Jinak řečeno – optimalizace překladače se řídí proměnnými a tím, kde jsou modifikovány a kde jsou čteny. Na tom je postavena hlavní (a vlastně jediná) část optimalizace. Optimalizátor zase není tak geniální, aby procházel zdrojový kód (nebo jej procházel dokonce víceprůchodově) a zkoumal všechny souvislosti.

Proto bych si dovolil tvrdit, že optimalizátor skutečně neprovede náhradu cyklu for za dva příkazy, kterými přiřadí do proměnných výsledek (jež by se dal očekávat po skončení onoho cyklu). Jednoduše – optimalizátor při překladu zmíněného cyklu for zjistí, že proměnnou y vůbec nepotřebujeme (její hodnotu nikde v programu – nebo spíše v oblasti, kde je viditelná – nečteme), proto ji s klidným svědomím může ignorovat. Důkazem budiž bod přerušení (breakpoint), který – umístíte-li jej na řádku

  y := x * 2;

vůbec nezabere.

Na druhou stranu optimalizátor snadno nahlédne, že proměnnou z po skončení cyklu potřebujeme, proto ji v žádném případě nevyhodí a její hodnotu počítá přesně tak, jak je psáno ve zdrojovém kódu. Pokud si na následující řádku ve zdrojovém kódu dáte bod přerušení, provádění programu se zastaví:

  z := x;

Optimalizátor totiž podrobně nezkoumá jednotlivé průchody cyklem: ví jen tolik, že proměnnou z musí držet v každém průchodu až do okamžiku, kdy nebude zřejmé, že provádění cyklu neskončí. Optimalizátor si zkrátka nepřidává do zdrojového kódu žádné testy říkající „proměnnou z nemusím počítat, dokud hodnota řídicí proměnné x bude menší než 2500000“. Optimalizátor pouze ví, že po skončení cyklu budeme proměnnou z potřebovat, proto s ní pracuje úplně normálně ve všech místech, které bychom mohli popsat slovy „od změny její hodnoty až po nabytí jistoty, že tuto hodnotu už určitě nebude nikdo číst.“

Tohle jsme si v podstatě popsali už před týdnem. Důsledkem toho ale je, že cyklus for zůstane cyklem for i při zapnuté optimalizaci.

V této souvislosti může vyvstat další otázka: jak by celá situace vypadala, pokud bychom ze zdrojového kódu vyhodili následující řádku:

  ShowMessage(`Hotovo, z = ` + IntToStr(z));

Mohli bychom stanovit následující hypotézu: „pokud by optimalizátor zjistil, že proměnnou z nikdo nebude číst, pak by vyhodil nejen proměnnou z, ale celý cyklus for, protože ten by rázem přestal dělat jakoukoliv užitečnou práci“.

Ale pozor: optimalizátor skutečně není všemocný ani geniální. Řekli jsme si, že optimalizátor optimalizuje především pomocí proměnných, takže bych spíše očekával, že bude společně s překladačem „uvažovat“ zhruba takto:

  • proměnnou y nikdo nebude číst, proto příkaz y := x * 2 překládat nebudu;
  • proměnnou z nikdo nebude číst, proto příkaz z := x překládat nebudu;
  • proměnná x je čtena vždy při testování, zda má cyklus skončit nebo zda má proběhnout další obrátka; proto proměnnou x překládat budu. Její hodnota musí být vždy u příkazu for x:=0 to 5 do begin k dispozici.

Překladač jednoduše „natvrdo“ uvažuje v intencích proměnných a žádných jiných.

Důsledkem je, že překladač přeloží cyklus for, který pětkrát proběhne a nic neudělá. Jsem si jist, že tuto hypotézu by potvrdily i případné testy doby provádění metody Button1Click. Prostě že při dostatečném počtu průchodů by procedura běžela mnohem déle, než kdyby v ní žádný cyklus for nebyl (ale přiznávám, že jsem tyto testy nedělal).

Jako další důsledek bychom dokonce mohli odhadnout, že pokud napíšeme následující fragment zdrojového kódu, překladač jej přeloží tak, jak je a žádná optimalizace se neprovede:

for x:=0 to 5 do begin
end;

A schválně: zkuste si na řádku začínající for umístit bod přerušení. Zastaví se provádění programu?

Ano, zastaví. Závěr našich empiricky ověřených hypotéz: optimalizátor se při své práci zabývá jen a pouze proměnnými a tím, v kterých místech programu budou jednotlivé z nich potřeba. Žádné další optimalizace neprovádí. Nelze od něj očekávat, že „vyhodí“ nějaké příkazy (byť by nic nedělaly – viz prázdný cyklus for).

Chcete důkaz? V následující kapitole je :-)

Optimalizátor optimalizuje jen proměnné: důkaz

Abych předchozí řádky nějak dokázal, bude nejlepší prozkoumat přímo výsledek práce optimalizátoru, totiž přeložené assemblerové instrukce.

Přitom se budeme držet stejného pořadí případů, jaké se objevilo v předchozí podkapitole. Chcete-li se přesvědčit společně se mnou, že optimalizátor skutečně pracuje tak, jak bylo popsáno, učiňte následující kroky:

Především vytvořte novou aplikaci, na formulář vložte tlačítko Button1 a napište obsluhu události OnClick tohoto tlačítka takto:

procedure TForm1.Button1Click(Sender: TObject);
var x, y, z: integer;
begin
  for x:=0 to 5 do begin
    y := x * 2;
    z := x;
  end;
  ShowMessage(`Hotovo, z = ` + IntToStr(z));
end;

Nyní umístěte kurzor na řádku s klíčovým slovem begin (hned pod definici proměnných x, y, z) a stiskněte klávesu F4 (Run to Cursor). Stejného efektu dosáhnete, když umístíte na řádku begin bod přerušení a pustíte aplikaci.

Výsledkem bude v každém případě, že se provádění programu zastaví bezprostředně poté, co klepnete na formuláři na tlačítko Button1 (tedy bezprostředně po vstupu do obsluhy Button1Click).

V takto přerušeném provádění programu si můžeme nerušeně prohlédnout, co překladač vlastně vytvořil. Zvolte tedy z hlavní nabídky Delphi položky View – Debug Windows – CPU, případně použijte klávesovou zkratku Ctrl-Alt-C.

Otevře se okno umožňující prohlížet obsah procesoru, jeho jednotlivých registrů, instrukcí, zásobníku, operační paměti a dalších „nízkoúrovňových“ míst. K tomu, abyste mohli provést dále uvedený rozbor, potřebujete alespoň základní povědomí o assembleru, o registrech procesoru a o jeho práci na nízké úrovni. Avšak pokud těmito znalostmi nedisponujete, klidně čtěte dál – věřím, že většina z uvedených informací pro vás bude přínosná a zajímavá, nicméně možná několikrát zažijete pocit popsatelný slovy klasika: „my mu nerozumíme, ale my mu věříme.“ :-)

Okno zobrazující obsah procesoru si můžete prohlédnou na následujícím obrázku:

Klepněte pro větší obrázek

Nyní se zaměříme především na největší část tohoto okna – na část zobrazenou vlevo nahoře. Ta totiž obsahuje přehled příkazů (ze zdrojového kódu) tak, jak byly překladačem přeloženy do instrukcí procesoru. Z těchto instrukcí snadno poznáme, jak to s optimalizací vlastně je.

Pojďme si tedy jednotlivé instrukce rozebrat. Nebudeme zkoumat všechny instrukce, ale zaměříme se až na ty, které následují od cyklu for. Podívejte se tedy na následující část výpisu:

Unit1.pas.30: for x:=0 to 5 do begin
004520F2    33C0      xor eax, eax

Unit1.pas.32: z := x;
004520F4    8BD8      mov ebx, eax

Unit1.pas.33: end;
004520F6    40        inc eax

Unit1.pas.30: for x:=0 to 5 do begin
004520F7    83F806    cmp eax, $06
004520FA    75F8      jnz -$08

Unit1.pas.34: ShowMessage(`Hotovo, z = ` + IntToStr(z));
004520FC    8D55F8    lea edx, [ebp-$08]

...atd.

Tento výpis vypadá trochu hrozivě, nicméně nemusíte se jej lekat, vysvětlíme si jej.

Abychom se v okně lépe vyznali, je vždy na první řádce uveden příkaz ze zdrojového kódu. Pod ním je (jsou) vždy uvedena (uvedeny) instrukce, která (které) jej realizuje (realizují). Nejprve tedy příkaz for po vstupu do funkce – tam se ještě neodehrává nic příliš zajímavého. Potom si však můžete všimnout, že následuje ihned příkaz z := x, tedy že příkaz y := x * 2 (který ve zdrojovém kódu předcházel), se nikde nevyskytuje. Optimalizátor zjistil, že y nebude potřeba, tak jej nepřeložil: zde vidíte nezvratný důkaz.

Příkaz z := x je realizován jako instrukce mov ebx, eax, tedy do registru ebx (který zde uchovává proměnnou z) se přesune obsah registru eax, který pro změnu uchovává hodnotu proměnné x. Jinak řečeno: hodnota proměnné z se normálním způsobem vypočítá, nastaví a uchová.

Ve zdrojovém kódu pak následuje příkaz end ukončující cyklus. Ten je zde realizován jako inc eax, tedy jako inkrementace registru eax uchovávajícího hodnotu proměnné x. Hodnota v registru se tedy zvýší o jedničku.

Pak si všimněte, že je v okně uveden opět příkaz pro cyklus for, nicméně tentokrát je realizován jinak než v úvodním případě, a to dvěma instrukcemi:

004520F7  83F806  cmp eax, $06
004520FA  75F8    jnz -$08

První instrukce (cmp eax, $06) porovná hodnotu aktuální proměnné x s počtem požadovaných průchodů cyklem (5) a druhá instrukce (jnz -$08) způsobí odskok v případě, že předchozí porovnání nedopadlo kladně. Jinak řečeno – pokud hodnota v eax není rovná požadovanému počtu průchodů, způsobí instrukce jnz odskok na adresu $004520F4, což je (jak se můžete sami přesvědčit) přiřazení mov ebx, eax.

Tímto způsobem cyklus probíhá tak dlouho, dokud instrukce cmp nevygeneruje nulu (a to se stane při posledním průchodu cyklem). Pak jnz nikam neskočí a vykonávání programu pokračuje dále instrukcí ShowMessage.

Z tohoto popisu je tedy patrné, že optimalizátor v žádném případě cyklus for nevyhodí, naopak jej ponechá v původní podobě, jen z něho vyhodí příkaz nastavující hodnotu proměnné y, protože tuto proměnnou nikdo nebude číst.

Další příklady

Pojďme se ještě ve stručnosti podívat na další příklady, které jsme nakousli v první části článku. Ptali jsme se, co se stane, pokud ze zdrojového kódu zrušíme volání funkce ShowMessage, tedy pokud po skončení cyklu nebudeme potřebovat ani jednu proměnnou z těch, které se v jeho těle počítají. Obsluha události ShowMessage bude tedy vypadat takto:

procedure TForm1.Button1Click(Sender: TObject);
var x, y, z: integer;
begin
  for x:=0 to 5 do begin
    y := x * 2;
    z := x;
  end;
end;

Pokud nyní zastavíte provádění programu na řádce

  for x:=0 to 5 do begin

a následně si zobrazíte obsah CPU, budete si moci prohlédnou následující instrukce:

Unit1.pas.30: for x:=0 to 5 do begin
0044D944    33C0    xor eax, eax

Unit1.pas.33: end;
0044D946    40      inc eax

Unit1.pas.30: for x:=0 to 5 do begin
0044D947    83F806  cmp eax, $06
0044D94A    75F8    jnz -$08

Unit1.pas.35: end;
0044D94C    75FA    ret

...atd.

Jinak řečeno: cyklus se opět přeložil, pouze z něho byly vypuštěny oba příkazy nastavující (nepotřebné) proměnné y, z.

Poslední případ se týkal situace, v níž překládáme prázdný cyklus:

procedure TForm1.Button1Click(Sender: TObject);
var x, y, z: integer;
begin
  for x:=0 to 5 do begin
  end;
end;

I v tomto případě dojde k jeho přeložení, čehož důkazem jsou následující instrukce:

Unit1.pas.30: for x:=0 to 5 do begin
0044D944    B806000000 mov eax, $00000006
0044D949    48        dec eax
0044D94A    75FD      jnz -$03

Unit1.pas.35: end;
0044D94C    8D4000    ret

...atd.

I v tomto případě je cyklus přeložen, jen jeho tělo je velmi redukováno (v podstatě se dekrementuje eax tak dlouho, až je nulové, a pak se pokračuje instrukcí ret pro návrat).

Na závěr

Dnešní článek se podrobně věnoval optimalizaci v Delphi. Ukázali jsme si, co optimalizátor udělá a co nikoliv: na vlastní oči jsme se přesvědčili, že hlavní náplní, kterou optimalizátor udělá, je vyházení nepotřebných proměnných a vůbec optimalizace na úrovni proměnných. Nelze od něho očekávat zrušení některých příkazů ani dlouhosáhlé analýázy zdrojového kódu.

Na druhou stranu je otázkou, proč by to měl umět. Chcete mít efektivní programy? Tak je pište efektivně :-)

Diskuze (28) Další článek: Bude 10 milionů uživatelů Mac OS X verze 10.3

Témata článku: Software, Programování, DEL, Zajímavý obsah, Překladače, Fragment, Atraktivní téma, První článek, Jak, Nezvratný důkaz, Jednotlivá místa, Zajímavé místo, Forum, Zajímavý případ, M-Button, Jediná část, Následující řádek, Deco, Move, První případ, Code, Díl, Následující instrukce


Určitě si přečtěte


Aktuální číslo časopisu Computer

Velký test fitness náramků

Levné záložní zdroje

Jak si zabezpečit domov

Nejlepší monitory na trhu