Jako optimální kód bych označil takový
zápis programu, který nemá žádné závažnější nedostatky ve svém výkonu, přestože
obecně panuje domněnka, že optimální rovná se nejrychlejší. Je to věc čistě
subjektivní a záleží především na tom, co od programu očekáváme. Malá utilitka
nebo středně velká aplikace nejspíš větší optimalizaci kódu potřebovat nebude,
opakem může být například 3D aplikace. Pokud dojde na optimalizaci, je dobré
vytyčit si jasný cíl a vědět kdy s ní skončit, například pokud si programátor
určí 28 fps ve své hře.
Rychlost vs. Čitelnost
Mnoho lidí se domnívá, že s rychlým kódem jde
ruku v ruce ošklivý a nečitelný kód. V některých případech tomu tak opravdu bývá
a vyhnout se tomu dá jen těžko, přesto však obecně platí, že takový kód může být
nejen dobře čitelný, ale dokonce i velmi elegantní.
Úroveň optimalizace
Při psaní složitějšího programu jste určitě někdy
narazili na to, že napsaný algoritmus je pro vaše potřeby příliš pomalý. Je
třeba rozhodnout, zda je daný algoritmus ten vhodný, a případně se poohlédnout
po jiném. Optimalizace má obecně dvě úrovně - optimalizace na úrovni algoritmu a
na úrovni implementace algoritmu. Jestliže si jste jisti, že algoritmus je
kvalitní, nebo přinejmenším nejlepší možný, přichází na řadu poupravit jeho kód,
tedy to, jakými prostředky Object Pascalu je zapsán. V žádném případě to
neznamená, že by jste se měli pokoušet optimalizovat každičký řádek svého
programu. Postačí vylepšit ty části, pro které je rychlost důležitá, nebo
identifikovat ty části, které jsou opravdu příliš pomalé. Velký klam je to,
že optimalizace přichází až tehdy, když je kód hotový. Mnohem lepší a z
programátorského hlediska také profesionálnější je naučit se psát kvalitní a
rychlý kód už od začátku. K tomu je ovšem potřeba se seznámit se všemi pravidly
psaní rychlého programů, a právě to má být hlavním tématem tohoto článku.
Hlavní zásady
V jednoduchosti je síla
Překladač (kompilátor, compiler) Delphi
automaticky při překládání tvého kódu provádí některé zásadní optimalizace,
obsahuje totiž vlastní optimizátor (optimizér, optimizer). Ten se stará
nejen o jednoduché věci jako je třeba odstraňování zbytečných řádků kódu, jeho
hlavní prací je pochopit náš "složitý" kód a upravit jej tak, aby se dal co
nejefektivněji přeložit do spustitelného souboru. Platí pravidlo, že čím je
kód jednodušší, tím úspěšnější optimizer je. Doporučuje se v jedné rutině
nepoužívat více než 5 proměnných (variables). Není také radno provádět během
jedné smyčky příliš mnoho operací, to totiž často vede k realokaci řídících nebo
pomocných proměnných. Často je výhodné dlouhé smyčky rozdělit do více
jednotlivých nebo v nich prováděné operace přemístit do samostatné funkce nebo
procedury (obecně rutiny). Příjemným vedlejším efektem bývá také zvýšení
čitelnosti takového kódu.
Používejte lokální proměnné
Jen lokální proměnné, tedy ty, které jsou
definované přímo v rutině, mohou být při jejím spuštění převedeny do
procesorových registrů, což je velmi významné pro rychlost. Někdy je výhodné
proměnné do lokálních proměnných vynuceně zkopírovat, zejména pokud jsou použity
v nějaké smyčce. To se týká hlavně vlastností tříd (tedy třeba
komponent). Výjimka z tohoto pravidla jsou pole (arrays) s elementy některého
jednoduchého typu (třeba integer, byte, char,
atd.). Ty je někdy výhodné zařadit mezi globální proměnné, ale samozřejmě jen
tehdy, pokud to má v rámci celého programu nějaký větší smysl.
Malé množství parametrů
Hodně často používané rutiny by neměly mít více
než tři parametry, neb tři je přesně počet proměnných, které mohou být v jeden
čas umístěny do registru. Tímto se rychlosti procesorových registrů využije na
maximum a optimizer dostane větší šanci vylepšit váš kód. Metody tříd však mají
skrytý parametr self, kterým odkazují na
konkrétní instanci dané třídy, zbývají tedy tak už jen dva parametry k umístění
do registrů.
Ukazatele
Cenná technika je využívat výhod, které přinášejí ukazatele
(pointers). Jejich používání přináší optimizeru mnoho informací o tvém
kódu, ale to samozřejmě neznamená abyste všechny objekty referencovali jako
ukazatele. Pokud definujete objekt třeba takto
var Soubor : TObject;
Ukazatel : Pointer;
možná
ani nevíte, že proměnná Soubor je vlastně
ukazatel, ukazuje na určité místo v paměti a navíc určuje, jaká data jsou v
daném paměťovém úseku zapsány. Toho lzes výhodou využívat, a do proměnných Pointer ukládat reference na objekty jakéhokoli typu.
begin
Soubor := TObject.Create;
Ukazatel := Soubor;
TObject(Ukazatel).Free;
end;
Pole
Pokud to jde, nepoužívejte dynamická pole. Práce s nimi je oproti
klasickým (tedy statickým) polím o dost pomalejší. Musíte-li je použít,
nezapomínejte na funkce High or Length pro
zjišťování velikosti pole. Ve skutečnosti fce High volá fci Length,
Length je proto doporučovanější. Nezjišťujte ale
opakovaně velikost pole těmito funkcemi, je rychlejší jej zjistit jednou a
uložit do lokální proměnné. Jednou z mnoha vlastností Delphi ulehčujících nám
práci jsou tzv. open arrays. Příkladem jejich použití budiž tento kód:
var MalaTabulka : Array[1..10] of byte;
VelkaTabulka : Array[1..100] of byte;
LibovolnaTabulka : Array of byte;
procedure VypisTabulku(Tabulka : array of byte);
var I : LongInt;
begin
for I := 0 to Length(X) - 1 do VypisHodnotu(Tabulka[i]);
end;
Jak vidíte,
parametr Tabulka neudává velikost předávaného
pole. Problémem této pohodlné věci je, že volání procedury VypisTabulku vytvoří lokální kopii celého pole, což,
kromě faktu že se tím plýtvá pamětí, není zrovna nejrychlejší operace. V tomto
případě je vhodné parametr Tabulka předznamenat
klíčovým slovem const nebo var, aby se tím zabránilo vytváření lokální kopie.
Ještě je třeba poznamenat, že použití const zabrání programátorovi ve změně
celého pole, ne už tak jeho jednotlivých elementů, takže pozor.
Konkrétní metody optimalizace
Množiny
Pokud pro přidávání/odebírání členů do/z množiny (set)
používáte klasické operátory (+ a -), vězte, že o něco rychlejší je práce s
rutinami Include a Exclude.
Smyčka for
Jedna z nejčastějších konstrukcí v Object Pascalu a zároveň
nejsložitější prácička pro optimizer. Například smyčka
for I := M to N do
A[i] := A[i] + B[i]
Bude
optimizerem převedena do
PA := @A[m];
PB := @B[m];
Pocitadlo := M - N + 1;
if Pocitadlo > 0 then
repeat
PA^ := PA^ + PB^;
Inc(PA);
Inc(PB);
Dec(Pocitadlo);
Until Pocitadlo = 0;
Existují jiné
možnosti, ale tato je nejčastější, a je z ní dobře vidět, kolik dodatečné práce
zdánlivě tak jednoduchá věc, jako je for smyčka,
vyžaduje. Platí tedy, že čím jednodušší svou smyčku uděláte, tím rychlejší bude,
a proto může být někdy lepší složité smyčky rozdělit do několika menších.
Interfacy
Nevyžadují příliš mnoho optimalizační údržby. Obsahují v sobě
tzv. reference counter (doslova přeloženo jako čítač referencí,
odkazů), který zajišťuje uvolnění paměti okupované jednou instancí. Pokaždé,
když je daný interface například přiřazen některé proměnné, zvýší se RC o jeden,
a analogicky se sníží v momentě kdy je některý odkaz na tento objekt zrušen.
Dosáhne-li RC nuly, je i objekt zrušen. Proto si dávej pozor a vyhýbej se známé
technice, která vyžaduje opakované rušení a znovuvytváření interface.
Paměťové zarovnávání
Používejte 32-bitové proměnné kdekoli a kdykoli je
to možné. Současné procesory jsou už nějaký ten pátek 32-bitové a práce s 4
bytovými bloky dat je proto nejrychlejší. Pro celočíselné proměnné to tedy
nejčastěji budou DWord, Longint nebo třeba Cardinal. Když musíte používat proměnné o jiné
velikosti (například z důvodů kompatibility), změňte je prostým přiřazením
nejdříve v 32-bitové, a až když je potřeba, zpět na původní velikost.
Zrychlování smyček
je jedna z nejčastějších optimizačních technik. V
angličtině se tomu říká loop unrolling. Z vlastních zkušeností ale můžu
říct, že tato technika je účinná jen u relativně malých smyček a to navíc jen
při použití konstrukce while. Viz. Tento
kód:
I := 0;
while I < Count do
begin
Data[I] := Data[I] + 1;
Inc(I);
end;
Tato smyčka může
být snadno zrychlena následujícím způsobem
I := 0;
while I < Count do
begin
Data[I] := Data[I] + 1;
Data[I + 1] := Data[I + 1] + 1;
Inc(I, 2);
end;
Tímto se sníží
počet průchodů while smyčkou. Problém této
techniky tkví v potřebě dodatečného kódu v případě, že Count je lichá hodnota a není tedy dělitelná dvěma, a
také v tom, že se tím komplikuje čitelnost kódu. Poznámka: Nemusíte se
samozřejmě omezovat jen na dvě operace uvnitř smyčky. Typicky se jich však pro
tento účel nedoporučuje používat více než 4, a to nejen kvůli přehlednosti, ale
zejména kvůli přílišné "složitosti" kódu pro optimizer. Další připomínkou je, že
vedle while se dá podobně dobře zrychlit i
konstrukce repeat.
Další pravidla pro smyčky
Uvnitř smyčky používejte co nejméně podmínek
if, zvláště když taková podmínka volá jiné
rutiny. Také se osvědčuje omezit množství řídících podmínek pro konstrukce while a repeat.
Samozřejmě se ale ve většině případů složitějším podmínkám nevyhneme.
Užívejte výhod Exit, Break a Continue
Přestože tyto vlastnosti Object
Pascalu bývají považovány za známku špatného a nekvalitního kódu, mají své
místo, a to hlavně tam, kde je důležitá rychlost. Pro ostatní případy se
doporučuje používat standardní konstrukce s podmínkami a bloky begin…end.
for a while
Tam, kde je předem známý počet průchodů smyčky, se
nejčastěji používají konstrukce for nebo while. Typicky bude zvolena spíše for, pro svou jednoduchost. V některých případech je
ale while o něco efektivnější a optimizer také
někdy, jak už jsem ukázal na příkladu výše, konstrukce for převádí na konstrukce while. Platí, že tam, kde jsou v těle smyčky použita
pole s jednotlivými elementy o velikosti 1, 2, 4, nebo 8 bytů, nebo nejsou pole
použita vůbec, je použití while výkonnější. Ale
tam, kde se objevují zejména vícerozměrná pole nebo pole s elementy o jiné
velikosti, bývá rychlejší naopak for.
Existuje však jedna výjimka - když není v těle smyčky nikde použita řídící
proměnná a jde vám tedy jen o určitý počet provedení nějakého kódu, je for efektivnější možností. Poznámka: Zajímavou
vlastností while v tomto případě je, že dosahuje
většího výkonu pokud řídící proměnná nabývá negativních hodnot a během
jednotlivých průběhů směřuje směrem k nule.
case
Implementace case je kompilátorem
poměrně dobře zvládnutá, i tato konstrukce se však dá dále optimalizovat.
Nejlépe si to ukážeme na příkladu:
Je zde vidět
poměrně veliká mezera mezi hodnotami 107 a 200, což brání optimizeru v
efektivnější optimalizaci kódu. Upravíme jej jako:
case x of
100..107 :
case x of
100 :DoSomething1;
101 :DoSomethingFrequently2;
102 :DoSomething3;
103 :DoSomething4;
104 :DoSomething5;
105 :DoSomething6;
106 :DoSomething7;
107 :DoSomething8;
end;
200..210 :
case x of
200 :DoSomething9;
201 :DoSomething10;
202 :DoSomething11;
203 :DoSomething12;
204 :DoSomething13;
205 :DoSomething14;
206 :DoSomething15;
207 :DoSomething16;
end;
end;
Když víte,
že některé hodnoty v case se objevují častěji, je
dobré je umístit ještě před samotnou konstrukci:
if X = 101 then
DoSomethingFrequently2 else
case X of
100 :DoSomething1;
102 :DoSomething3;
103 :DoSomething4;
104 :DoSomething5;
105 :DoSomething6;
106 :DoSomething7;
107 :DoSomething8;
end;
Výčtové typy
Pokud to jde, vyhněte se výčtovým typům. Například typ
TRozsah = 0-6000 bude uložen interně jako Word, což, jak jsme si už řekli, je neefektivní.
Podobně bude množina o dejme tomu 250 elementech uložena jako byte.
Celočíselné násobení
Násobení celočíselných typů je doslova utrpením pro
starší procesory jako je 80486, Pentium či Pentium Pro. Situace se zlepšila s
příchodem Pentia II. Kompilátor si toho je vědom a všude, kde to jen trichu jde,
nahrazuje takové násobení jinými operacemi. Pokud tedy píšete kód, pro který je
rychlost kritická, musíte uvažovat i cílový systém a optimalizaci tomu
přizpůsobit.
Zkracování podmínek
Porovnáváte-li proměnnou s více jednoduchými typy
najednou, je přehlednější a efektivnější použít místo nich výčet. Nejlepší bude
ukázat si to na příkladu:
if (((C > = 'a') and (C < = 'z')) or ((C > = '0') and (C < = '9'))) then
DoSomething;
// změňte na
if C in ['0'..'9', 'a'..'z'] then
DoSomething;
// případně na
case C of
'0'..'9', 'a'..'z' : DoSomething;
End;
Dlouhé řetězce (AnsiString)
Nepoužívejte asociace pro počáteční
vynulování řetězce na začátku rutiny. AnsiString
(a tedy tím pádem string při standardním
nastavení Delphi) je automaticky při své inicializaci nastaven na prázdný
řetězec. Používání operací typu S := S + S2[I] ve
smyčkách způsobuje dodatečnou a pomalou alokaci paměti při každém takovém
zvětšení stringu. Abyste tomu předešli, zavolejte nejdříve SetLength(S, Nova_Delka). Toto je bohužel velmi častá
chyba programátorů, způsobená jen tím, že zvětšování a zmenšování dynamických
řetězců je v Delphi obstaráváno automaticky.
Procedura Copy
Častokrát je k vidění, že programátoři pro odstranění
určité části řetězce používají funkci Copy.
Například odstranění prvních 20ti znaků může vypadat takto:
S := Copy(S, 21, Length(S) - 20); // nesprávně!!!
Delete(S, 1, 20); // mnohem, mnohem rychlejší
string jako PChar
Potřebujete-li z nějakých důvodů na proměnou
dynamického řetězce (definovanou třeba jako S)
odkazovat jako na PChar, máte tři ekvivalentní
možnosti, přičemž nejrychlejší je ta poslední. První je použít přímo PChar(S). Druhá tkví v referenci na první znak řetězce,
tedy @S[1]. Tou poslední je ukázat na řetězec
přímo ukazatelem, použít tedy klasický Pointer:
Pointer(S).
Datové typy s plovoucí čárkou
Tyto floating types poskytují velikou
flexibilitu, ale také mohou zabrat mnoho paměti. Stále platí, 32-bitové typy
jsou nejrychlejší. Povětšinou ale používáme tyto typy také pro jejich často
veliký rozsah, proto někdy je nutné oněch 32-bitů nedodržet. První pravidlo
zní, používejte Extended jen když je to nezbytně
nutné. Jejich velikost (10 bytů) je zejména pro základní operace (především +,
-, *) vysoce neefektivní, a projeví se to ve ztrátě výkonu. Dále, snažte se
tyto typy nemixovat - pokud už musíte používat některé z těchto "desetinných"
typů, drž se jen jednoho z nich. V případě, že definujete konstantu některého
typu s plovoucí čárkou, udělejte z ní konstantu s přiřazeným datovým typem, a to
Single nebo Double (const
X : Single;). Pokud to neuděláte, bude uložena jako Extended, což je silně nepraktické. Namísto Trunc používejte Round.
Ta je na Pentiu II přibližně 2,74x rychlejší. Vyhýbejte se dělení kdekoli to
jen jde, zejména uvnitř smyček. Je asi 35x pomalejší než ostatní běžné operace
(!).
Poslední rada ohledně těchto typů říká, nepoužívejte je jako výstup
funkce. Místo toho definujte proceduru a daný výstup vracejte pomocí var:
function Fnc(X : Double) : Double;
begin
Result := X * 1.8 + 2;
end;
// po úpravě
procedure Fnc(X : Double; var Result : Double)
begin
Result := X * 1.8 + 2;
end;