Optimointi
Optimoinnin perusteet yleisestä teoriasta yksittäisiin hyväksi näkemiini optimointitekniikoihin. Koodiesimerkeissä käytetään C-kieltä.
15.4.2004 julkaistun artikkelin on kirjoittanut sebbbi.
1. Yleistä #
Tarkoituksenani on tässä artikkelissa käydä läpi erilaisia optimointitekniikkoja yleisellä tasolla. Artikkelista olen pyrkinyt tekemään suhteellisen kattavan, jopa jossain suhteessa yleissivistävänkin. Kuten mitä tahansa artikkelia lukiessa, kannattaa suhtautua artikkelin koko sisältöön pienellä varauksella. Artikkeli on vain minun näkemykseni optimoinnista ja siihen liittyvistä vaaroista.
Optimoinnilla tarkoitetaan asioiden suorittamisen tehokkaammiksi muuttamista, tai mahdollisimman tehokkaan tavan löytämistä. Ohjelmoinnin alueella tällä tarkoitetaan yleisesti suorituskyvyn parantamista. Huomioitavaa on, että termiä voidaan käyttää myös muihin tarkoitusperiin (reitin optimointi, lineaarinen optimointi...). Tässä artikkelissa puhun kuitenkin pelkästään ohjelmakoodin suorituskyvyn optimoinnista.
2. Mitä optimoida? #
Ensimmäinen ja myös tärkein asia optimoinnissa on se, mihin optimointi kannattaa kohdistaa. Jos aikaa olisi loputtomasti, olisi varmaan järkevää optimoida jollain tasolla kaikki tuotettu ohjelmakoodi. Näin ei kuitenkaan ikinä tule tapahtumaan. Aina on jotain muuta tärkeämpää tehtävää, johon ajan voi laittaa: uudet ominaisuudet, bugejen korjaus, tuotteen viimeistely ja hiominen, tärkeämpien kohtien optimointi paremmin...
Optimointi kannattaa kohdistaa ohjelman aikakriittisiin kohtiin, koska suurin osa ohjelman suoritusajasta kuluu näissä kohdissa. Aikakriittisten kohtien löytäminen koodista vaatii pientä harjoittelua. Pääsääntönä voi pitää sitä, että mitä useammin koodirivi suoritetaan, sitä aikakriittisempi se on. Näinollen optimointia ei siis kannata kohdistaa esimerkiksi kerran ajettaviin koodiriveihin menun käynnistyksessä, vaan paljon mielummin vaikka koodiriveihin, jotka hoitavat yksittäisen pikselin ruudulle piirtämisen (joita suoritetaan miljardeja kertoja pelin aikana).
Aikakriittisten kohtien löytämiseen voi käyttää myös useiden ohjelmointiympäristöjen mukana tulevia profiler-apuohjelmia, jotka raportoivat yksittäisten funktioiden ja koodirivien viemät suoritusajat. Kun ohjelmaa suoritetaan, profileri laskee koodirivien suoritukseen kuluneet ajat ja kirjaa ne ylös. Suorituksen jälkeen ohjelmoija voi tutkailla profilerin tuottamaa tulostetta ja saada tietoa ohjelman aikakriittisistä osista. Näin optimoinnin kohdistaminen oikeisiin kohtiin helpottuu huomattavasti.
Yksi pahimmista virheistä optimoinnissa on ylioptimointi. Tällä termillä tarkoitan sitä, että ohjelmoija on liian innokas optimoimaan ohjelmakoodin kohtia joiden optimoinnista seuraa vain marginaalinen hyöty. Yleensä nämä optimointikohteet ovat ohjelman arkkitehtuurissa korkeammalla tasolla. Korkeamman tason koodin optimointi helposti rappauttaa ohjelman rakenteen ja tekee ohjelmasta vaikeasti ymmärrettävän. Ohjelman ylläpidettävyys ja muokattavuus kärsii. Varoittavia esimerkkejä tämän tason optimoinneista ovat esimerkiksi: rajapintojen ohittaminen ja karsiminen (suora käskytys on nopeampaa), julkiset atribuutit (pieni nopeuslisä), korkean tason luokasta ulospäin näkyvät rakenneoptimoinnit (vaikeuttavat luokan käytettävyyttä).
Optimointi kannattaa aina kohdistaa mahdollisimman alhaiselle tasolle ohjelmassa. Optimointi on onnistunut, jos luokan ulkopuolinen käyttäjä ei näe eroa optimoidussa ja optimoimattomassa luokassa.
3. Milloin optimoida? #
Lähes yhtä tärkeää kuin tietää mitä optimoida on tietää milloin optimoida. Olen usein kuullut sanonnan "kun sen kerran tekee kunnolla, ei siihen tarvitse enää palata". Valitettavasti tämä sanonta ei suoraan toimi optimoinnista puhuttaessa. Parempi sanonta olisikin "rakenna ensin kestävä perusta, perustan päälle talo, kalusta se ja viimekseksi koristele se, jos on tarvetta".
Uutta ohjelmakomponenttia kehittäessä on tärkeää saada aluksi ohjelmakomponentin rakenne valmiiksi. Tämän jälkeen on tärkeää, että komponentti toimii oikein. Näiden vaiheiden loppuun saamiseksi ohjelmoija tuottaa useita rivejä koodia ja käy tuottamaansa koodia useaan kertaan läpi ja korjailee tekemiään virheitä (bugeja). Vihdoin ohjelmakomponentti toimii "oikein" ja sitä voidaan testata toisien komponenttien kanssa yhdessä. Tässä vaiheessa löydetään vielä liuta uusia virheitä ja nämä korjataan.
Kehitysvaiheessa olevan ohjelmakomponentin koodin muutospaineet ovat suuret. Jos ohjelmakomponentti optimoidaan heti alussa, hankaloituu sen kehitys. Optimoitua koodia on huomattavasti hitaampaa tuottaa, hitaampaa muokata ja hitaampaa ymmärtää kuin optimoimatonta. Optimoidun koodin muuttaminen johtaa helposti optimointejen purkamiseen ja uusien optimointejen lisäämiseen. Tästä seuraa helposti virheitä ja näiden virheiden löytämiseen menee taas lisää aikaa.
Komponentti kannattaa optimoida kun se on valmis ja kunnolla testattu. Jos komponenttiin kohdistuvat muutospaineet ovat tämänkin jälkeen suuret, kannattaa optimointi kohdistaa komponenttiin vain siinä tapauksessa, jos siinä on osia, jotka ovat erittäin aikakriittisiä.
4. Optimointikohde: Tietorakenteet #
Tärkein optimointikohde on ohjelman tietorakenteet. Tietorakenneoptimoinneilla saavutetaan kaikista optimoinneista suurin hyöty pienimmällä vaivalla. Usein tietorakenneoptimoinnit eivät hankaloita koodin ymmärrettävyyttä lainkaan (olettaen, että lukija osaa käytetyt tietorakenteet). Valitettavasti näyttää siltä, että suurin ja ylivoimaisesti yleisin optimointivirhe on unohtaa tietorakenneoptimoinnit kokonaan ja keskittää voimavarat pelkästään algoritmien optimointeihin.
Yleisimpiä tietorakenteita ovat: taulukko, lista, jono, puu, pino, keko, hajautustaulu, hakemisto (indeksi)... Useimmista näistä on lukuisia variaatioita (esim. järjestetty taulukko, kahteen suuntaan linkitetty lista). Jokainen tietorakenne sopii tiettyyn tapaukseen muita rakenteita paremmin. Tärkeää on, että tietää tietorakenteiden heikkoudet ja vahvuudet, jotta osaa valita käyttötarkoitukseensa optimaalisimman tietorakenteen.
Tietorakenteita arvioidessa on tärkeää tietää kuinka aikaavievä mikäkin tietorakenteen operaatioista on. Yleisimpiä operaatioita ovat seuraavat:
[DEFTABLE]
[D]Alkion hakeminen[/D]
[E]- Ensimmäisen / viimeisen alkion hakeminen
- Suurimman / pienimmän alkion hakeminen
- Halutun alkion hakeminen (alkion tunnisteella)
- Alkion hakeminen halutusta indeksistä
- Alkioiden hakeminen tietyllä hakuehdolla[/E]
[D]Alkion lisääminen[/D]
[E]- Alkion lisääminen alkuun / loppuun
- Alkion lisääminen haluttuun paikkaan
- Alkion lisääminen sille kuuluvaan paikkaan (puut ja järjestetyt rakenteet)[/E]
[D]Alkion poistaminen[/D]
[E]- Ensimmäisen / viimeisen alkion poistaminen
- Suurimman / pienimmän alkion poistaminen
- Halutun alkion poistaminen (alkion tunnisteella)
- Alkion poistaminen halutusta indeksistä
- Alkioiden poistaminen tietyllä hakuehdolla[/E]
[/DEFTABLE]
Operaatiot jakautuvat siis kolmeen kategoriaan: haku, lisäys ja poisto. Näiden operaatioiden merkitys vaihtelee hieman tietorakenteittain. Esimerkiksi puurakenteissa, keossa ja järjestetyissä listoissa/taulukoissa alkio lisätään aina sille kuuluvaan paikkaan (avainarvon mukaan), kun taas järjestämättömissä listoissa/taulukoissa vaihtoehtoja on useampia (alkuun, loppuun, haluttuun paikkaan).
Kun tiedät mitä operaatioita tarvitset tietorakenteeltasi ja kuinka paljon kutakin käytät, voit analysoida ongelmaasi sopivia tietorakenteita ja valita niistä sopivimman.
Esimerkiksi tavallisella taulukolla alkion hakeminen indeksillä on erittäin nopeaa, mutta toisaalta taas alkion lisääminen ja poistaminen taulukon keskeltä on erittäin raskasta. Taulukkoa kannattaa siis käyttää silloin, kun indeksoituja hakuja tulee suhteessa paljon verrattuna lisäyksiin ja poistoihin.
Tietorakenteista löytyy huomattava määrä kirjallisuutta. Suosittelen jokaiselle ohjelmoijalle tutustumista tietorakenteisiin, vaikka optimointi ei itsessään edes kiinnostaisi.
5. Optimointikohde: Algoritmit #
Kun ohjelman tietorakenteet on optimoitu viimeisen päälle ja vielä tarvitaan lisää tehoja, on siirryttävä algoritmien optimointiin. Algoritmien optimoinnilla tarkoitan tässä alemman tason optimointeja, jotka eivät muuta tietorakenteita.
Tärkein asia algoritmioptimoinnissa on varmistaa, että käytössä on oikea algoritmi, joka sopii parhaiten haluttuun ongelmaan ja sopii käytetyille tietorakenteille. Esimerkiksi lajittelualgoritmeja on lukuisia erilaisia ja algoritmit vaihtelevat huomattavasti suorituskyvyltään. Parhaiden yleiskäyttöisten lajittelualgoritmien aikavaativuus on n * log(n), kun n on alkioiden määrä.
Tässä lyhyt oppimäärä aikavaativuusluokituksista. Aikavaativuudet on järjestetty nopeimmasta hitaimpaan:
1
Kiinteä aikavaativuus. Tähän aikavaativuusluokitukseen kuuluvat kaikki operaatiot jotka suoritetaan vain kerran (riippumatta alkioiden määrästä).
operaatio()
Esimerkkejä algoritmeista: haku/lisäys/poisto hajautustaulusta (jossa ei ole yhtään yhteentörmäyksiä), lisäys listan alkuun/loppuun, alkion hakeminen indeksillä taulukosta.
100:lla alkiolla operaatioiden suoritusmäärä: 1
log(n)
Logaritminen aikavaativuus. Kulutettu aika on logaritminen suhteessa alkioiden määrään. Logaritminen algoritmi saa yhdellä askeleella "käytyä läpi" aina tietyn osuuden järjellä olevista alkioista (esimerkissä 50% alkioista).
for (int a=100; a>0; a/=2) operaatio();
Esimerkkejä algoritmeista: binäärihaku, lisäys/haku puurakenteesta.
100:lla alkiolla operaatioiden suoritusmäärä: log2(100) = 7
n
Lineaarinen aikavaativuus. Kulutettu aika on lineaarinen suhteessa alkioiden määrään. Lineaarinen algoritmi saa yhdellä askeleella aina käytyä läpi tietyn määrän alkioista (esimerkissä yhden alkion).
for (int a=0; a<100; a++) operaatio();
Esimerkkejä algoritmeista: haku järjestelemättömästä listasta, keskiarvon yms laskeminen alkioista, tietorakenteen kopiointi.
100:lla alkiolla operaatioiden suoritusmäärä: 100
n * log(n)
Lineaarinen * logaritminen aikavaativuus. Tälläinen aikavaativuus syntyy kun yhdistetään lineaarinen ja logaritminen algoritmi. Kaikki yleisimmät järjestelyalgoritmit ovat tätä aikavaativuusluokkaa.
for (int a=0; a<100; a++) for (int b=100; b>0; b/=2) operaatio();
Esimerkkejä algoritmeista: Lukuisat eri järjestelyalgoritmit (heapsort, qsort...)
100:lla alkiolla operaatioiden suoritusmäärä: 100*log2(100) = 700
n^2
Neliöllinen aikavaativuus. Kulutettu aika on neliöllinen suhteessa alkioiden määrään. Tälläinen algoritmi syntyy, kun joudutaan käymään jokaista alkiota kohden jokainen alkio läpi.
for (int a=0; a<100; a++) for (int b=0; b<100; b++) operaatio();
Esimerkkejä algoritmeista: (TODO)
100:lla alkiolla operaatioiden suoritusmäärä: 100*100 = 10000
n^3
Kuutiollinen aikavaativuus. Kulutettu aika on kuutiollinen suhteessa alkioiden määrään. Tälläinen algoritmi syntyy, kun joudutaan suorittamaan n^2 aikavaativuusluokkaa oleva algoritmi jokaiselle alkiolle.
for (int a=0; a<100; a++)
for (int b=0; b<100; b++)
for (int c=0; c<100; c++) operaatio();
Esimerkkejä algoritmeista: (TODO)
100:lla alkiolla operaatioiden suoritusmäärä: 100*100*100 = 1000000
Aikavaativuusluokkia on näiden lueteltujen lisäksi olemassa lukuisia. Nämä ovat vain yleisimmät. Suurin osa käytetyistä algoritmeista voidaa luokitella johonkin näistä vaativuusluokista.
Tärkeää on huomioida, että operaatio():n optimointi nopeammaksi ei muuta aikavaativuusluokitusta lainkaan. Vaikka saisit operaatio():n optimoitua 10 kertaa nopeammaksi, ei sen vaikutus olisi (100:lla alkiolla) mitään verrattuna siihen, että saisit muutettua algoritmin aikavaativuusluokitusta. Operaation optimointi muuttuu tärkeämmäksi, kun alkioiden määrä vähenee.
Erilaisia algoritmeja on kehitetty vuosien varrella lukuisia. Välttämättä ei kannata keksiä pyörää uudelleen, vaan helpommalla pääsee käyttämällä jo vuosikaudet käytössä olevaa viimeisen päälle testattua ja analysoitua algoritmia. Algoritmeista löytyy kirjallisuutta huomattava määrä. Helpoiten haluamansa algoritmin löytää internetistä, joko hakukoneella tai joltain algoritmeihin erikoistuneelta sivustolta.
6. Operaation optimointi #
Vihdoinkin päästään aiheen ytimeen, eli siihen "oikeaan" optimointiin. Kun tietorakenteiden ja algoritmien valinta on helppoa yleisen oppikirjatietouden avulla, alemman tason operaatioiden optimointia voidaan kutsua jo omaksi taiteen lajikseen. Tämän tason optimoinneissa tärkeää on tietää miten laitteisto toimii, ja luoda ohjelmakoodia, joka hyödyntää mahdollisimman täydellisesti laitteiston antamat resurssit, välttäen laitteiston lukuisia pullonkauloja.
6.1 Mikä maksaa nykyisillä PC arkkitehtuureilla
Ennen kun aloitetaan optimointi on tärkeää tietää laitteiston rajoitteet, pullonkaulat ja operaatioiden aikavaativuudet.
Nykyiset PC:t ovat erittäin monimutkaisia laitteita. Prosessorin lisäksi koneesta löytyy huomattava määrä muitakin aikakriittisiä komponentteja: muistit, muistiväylä, muistiohjain, näytönohjain, AGP/PCI väylä. Nämä kaikki komponentit yhdessä vaikuttavat lopullisen ohjelmakoodin suoritusnopeuteen. Pullonkaulaksi sanotaan sitä komponenttia johon kohdistuu suurin rasitus, ja tämän komponentin takia muut komponentit joutuvat odottamaan. Esimerkiksi hitaan muistiväylän takia prosessori ei saa tarpeeksi tietoa käsiteltäväkseen ja joutuu odottelemaan tiedon saapumista. Hyvin optimoitu ohjelmakoodi rasittaa laitteistoa tasaisesti. Näin pullonkauloja ei pääse syntymään.
6.2 Käskyjen optimointi
Aloitetaan helpoimmasta, eli käskyjen optimoinneista. Vanhemmilla ja yksinkertaisimmilla arkkitehtuureilla prosessori oli usein ainut laitteiston pullonkaula. Tälläisen laitteen suorituskyvyn optimointi tapahtuu yksinkertaisesti optimoimalla käskyjä. Mitä vähemmän käskyjä ja mitä halvempia käskyjä suoritetaan, sitä nopemmin ohjelman suoritus etenee.
Analysoin peruskäskyjen suoritusaikoja koneellani, ja sain seuraavanlaisen tuloksen:
Yhteenlasku (+), vähennyslasku (-), shiftaukset (<< ja >>), bittioperaatiot (&, |...), vertailu (if): 1 aikayksikkö
Kertolasku (*): 2 aikayksikköä
Funktiokutsu yhdellä parametrilla ja paluuarvolla: 4 aikayksikköä
Int/float konversio (int(), float()): 8 aikayksikköä
Jakolasku (/), modulo (%): 20 aikayksikköä
Neliöjuuri (sqrt): 30 aikayksikköä (huomioi: liukuluvuilla suoritettu)
Sini (sin), kosini (cos): 80 aikayksikköä (huomioi: liukuluvuilla suoritettu)
Kannattaa huomioida, että tämä tulos on vain suuntaa-antava. On hyvin pitkälle prosessorin arkkitehtuurista kiinni, miten nopeasti se näistä käskyistä suoriutuu. Järjestys on kuitenkin hyvin pitkälle aina tämä. Kannattaa siis suosia listan alkupään halpoja käskyjä ja välttää loppupään huomattavasti kalliimpia käskyjä.
Yleisin optimointikohde ovat silmukat. Silmukan sisällä käskyt suoritetaan selvästi useammin kuin sen ulkopuolella. Usein on mahdollista siirtää osa laskennasta silmukasta ulos (tai ulompaan silmukkaan). Näin siirretyn käskyn aikavaativuusluokitus (kts. kappale 5) laskee.
Esimerkki:
Tässä alkuperäinen koodinpätkä. Suoritettavien operaatioiden määrä on seuraava:
Kertolasku: 400000000
Yhteenlasku: 400000000 * 2 (+ luupit: 400000000 + 20000)
Vertailu: 400000000 + 20000
int tulos=0;
for (int a=0; a<20000; a++)
{
for (int b=0; b<20000; b++)
{
tulos+=a*20000+b;
}
}
Optimoidussa versiossa kertolasku on siirretty ulompaan silmukkaan. Tämän ansiosta kertolaskujen määrää on saatu vähennettyä 20000 kertaisesti:
Kertolasku: 20000
Yhteenlasku: 400000000 * 2 (+ luupit: 400000000 + 20000)
Vertailu: 400000000 + 20000
for (int a=0; a<20000; a++)
{
const int a2k=a*20000;
for (int b=0; b<20000; b++)
{
tulos+=a2k+b;
}
}
Kun tarkkailemme ohjelmakoodia vielä hetken, huomaamme, että ulomman silmukan kertolaskun voi korvata yhteenlaskulla. Tällä optimoinnilla saamme vielä pienen ripauksen lisää tehoja irti.
Kertolasku: 0
Yhteenlasku: 400000000 * 2 + 20000 (+ luupit: 400000000 + 20000)
Vertailu: 400000000 + 20000
int a2k=0;
for (int a=0; a<20000; a++)
{
for (int b=0; b<20000; b++)
{
tulos+=a2k+b;
}
a2k+=20000;
}
Näiden algoritmien käyttämä aika on koneellani seuraavanlainen:
A: 2170 millisekuntia
B: 1650 millisekuntia (76%)
C: 1640 millisekuntia (76%)
Huomamme, että nopeus lisääntyi optimointejen ansiosta. Ensimmäinen optimointi (A->B) poisti 399980000 kertolaskua ja toinen optimointi (B->C) muutti 20000 kertolaskua 20000 yhteenlaskuksi. On selvää, että ensimmäinen optimointi auttoi suorituskykyyn huomattavasti toista enemmän.
Laskemme nyt edellä laskemieni operaatioiden suoritusaikojen perusteella arvion algoritmin kuluttamasta ajasta. Yhteenlasku kuluttaa siis yhden aikayksikön ja kertolasku kuluttaa 2.
A: 400000000 * 2 + (400000000 * 2 + 400000000 + 20000) + 400000000 + 20000 = 2400040000
B: 20000 * 2 + (400000000 * 2 + 400000000 + 20000) + 400000000 + 20000 = 1600080000 (67%)
C: 400000000 * 2 + 20000 + 400000000 + 20000 + 400000000 + 20000 = 1600060000 (67%)
Pääsemme näillä arvioilla yllättävänkin lähelle oikeaa lopputulosta, kun huomioimme, että nykyprosessorit ajavat useita käskyjä rinnakkain. Suorituskyvyn arviointi on tämän takia huomattavasti vaikeampaa, kuin vanhemmilla prosessoreilla, jotka ajoivat käskyjä peräkkäin aina yhden kerrallaan.
Yhteenvetona: Käskyjen optimoinnissa tärkeintä on käskyjen määrän minimointi, ja raskaampien käskyjen vaihtaminen halvemmiksi.
6.3 Lookup-taulukot
Lookup-taulukot ovat yksi vanhimmista optimointitekniikoista. Ideana on laskea monimutkaisen matemaattisen operaation tulokset valmiiksi taulukkoon. Tätä taulukkoa voidaan sitten käyttää operaation sijasta. Näin toistuvasti kutsuttu monimutkainen matemaattinen operaatio korvaantuu yksinkertaisella taulukon alkion lukemisella. Suorituskykylisä riippuu täysin korvattavasta operaatiosta. Mitä raskaampi operaatio korvataan lookup-taulukolla, sitä suurempi on hyöty.
Esimerkki:
Pelissä käytetty partikkelisysteemi tarvitsee satoja tuhansia kertoja sekunnissa laskea partikkelien vauhtilisäyksiä partikkelin kääntyvän suunnan mukaan. Vektorien komponenttien laskemiseen käytetään siniä ja kosinia. Pelissä täyskulma on jaettu 256:een osaan (arvot 0-255).
Tätä kyseista tapausta varten tarvitaan siis 256 alkion kokoiset lookup-taulukot sinin ja kosinin arvoja varten. Trigonometriaa enemmän harrastaneet tietävät, että yksikköympyrä jakautuu 4:ään osaan, jotka ovat täysin symmetrisiä keskenään. Taulukon kooksi riittää siis 1/4 täydestä kulmasta, eli 64. Yhtälailla on myös totta, että cos(x + pi/2) = sin(x). Näinollen kummankin operaation laskemiseen voidaan käyttää samaa lookup-taulukkoa.
const float PI = 3.14159265358f;
float sincos_lookup[64];
void initialize_lookups()
{
for (int i=0; i<64; i++) sincos_lookup[i] = sin(PI / 2.0 * (float) i / 64.0);
}
float table_sin(unsigned int angle)
{
// Rajoitetaan kulma välille 0-255 ( = täyskulma)
angle &= 0xff;
if (angle < 64)
{
return sincos_lookup[angle];
}
else if (angle < 128)
{
return sincos_lookup[127 - angle];
}
else if (angle < 192)
{
return -sincos_lookup[angle - 128];
}
else
{
return -sincos_lookup[255 - angle];
}
}
float table_cos(unsigned int angle)
{
// Rajoitetaan kulma välille 0-255 ( = täyskulma)
angle &= 0xff;
if (angle < 64)
{
return sincos_lookup[63 - angle];
}
else if (angle < 128)
{
return -sincos_lookup[angle - 64];
}
else if (angle < 192)
{
return -sincos_lookup[191 - angle];
}
else
{
return sincos_lookup[angle - 192];
}
}
Nopeasti laskettuna optimoidussa versiossa on: 3 vertailua ja yksi bittioperaatio. Kaikki nämä kuluttuvat yhden aikayksikön, eli yhteensä 4 aikayksikköä. Näiden lisäksi funktion kutsuminen, palautusarvon antaminen, tietotyyppikonversio ja taulukosta luvun hakeminen vievät oman aikansa. Yhteensä siis 4 + 4 + 8 = 16 aikayksikköä (kun taulukosta lukemista ei oteta huomioon). Normaali sini vaatii 80 aikayksikköä, eli optimointi tuntuu selvästi järkevältä.
Mittasin optimoidun algoritmin ja normaalin sinin välisen suorituskykyeron testimittauksella. Kummallakin algoritmilla oli syötteinä identtisesti generoitu testisyötejoukko:
Normaali sini: 35800 millisekuntia
Optimoitu (lookup): 8900 millisekuntia (25%)
Huomataan että optimoitu algoritmi on näillä syötteillä 4 kertaa nopeampi. Kun huomioidaan, että alkuperäinen sini vei 80 aikayksikköä, niin optimoidun algoritmin pitäisi viedä 80 / 4 = 20 aikayksikköä. Laskennallinen ajanvienti optimoidulle algoritmille oli 16 yksikköä. Virhemarginaali on suhteellisen pieni (20%). Tässä vaiheessa voin vain todeta, että lookup-optimoinneilla saavutettu hyötysuhde ei ole koskaan täydellinen (100%). Miksi näin käy? Tätä asiaa tutkimme syvemmin kappaleessa 6.4.
Yhteenvetona: Lookup-taulukoilla voidaan korvata laskennallisesti vaativa usein toistuva operaatio yksinkertaisella taulukon alkion lukemisella. Mitä vaativampi operaatio korvataan sitä suuremmat ovat hyödyt. Lookup-taulukoiden käytössä on myös omat vaaransa. Näitä käsitellään tarkemmin kappaleessa 6.4.
6.4 Rekisterit, välimuisti, muistiväylä ja keskusmuisti
Kohdassa 6.2 ja 6.3 oletimme, että käskyjen suoritus ratkaisee täysin ohjelmakoodin suoritusnopeuden. Jos kaikki käsiteltävä tieto mahtuisi prosessorin sisään (rekistereihin), näin olisikin. Nykyiset ohjelmistot käsitettelevät useita satoja megatavuja tietoa. Tieto on jakaantunut prosessorin rekisterien lisäksi prosessorin välimuistiin ja koneen keskusmuistiin. Mahdollisesti prosessorin välimuistin ja keskusmuistin välissä on vielä piirisarjan oma välimuisti (esimerkiksi nForce-piirisarjan DASP). Muistit ovat yhteydessä toisiinsa muistiväylien välityksellä. Mitä leveämpi väylä, sitä nopeampi on tietoa siirtää väylää pitkin.
Prosessori suorittaa kaikki operaatiot rekistereissä olevalla tiedolla. Tieto joudutaan siirtämään muistista rekistereihin ennen kun sitä voidaan prosessoida. Rekistereihin mahtuu yhteensä vain muutamia kymmeniä tavuja tietoa. Tämän takia tietoa joudutaan hakemaan rekistereihin ja siirtämään niistä pois erittäin tiheään tahtiin.
Välimuistin (cache) tarkoitus on antaa prosessorille lisää tilaa useasti tarvitun tiedon tallentamiseen. Välimuistista tiedon hakeminen on huomattavasti nopeampaa kuin keskusmuistista. Välimuistiin tietoa haetaan keskusmuistista ennakoiden. Ennakointi suoritetaan yksinkertaisella periaateella: Tietoa käsitellään useimmiten peräkkäin. Jos ohjelma käyttää tietoa muistipaikasta X, on todennäköistä, että se käyttää pian myös tietoa muistipaikan X läheisistä muistipaikoista. Näin siis kannattaa ladata välimuistiin myös muistipaikan X läheinen tieto (isompi blokki tietoa). Kun tieto on valmiiksi ladattuna välimuistiin, vältetään viive, joka aiheutuisi prosessorin joutuessa odottamaan tietoa saapuvaksi keskusmuistista.
Välimuistin koko vaihtelee peruskäyttöön suunnatuilla nykyprosessoreilla välillä 128 kilotavua - 512 kilotavua. Serverikäyttöön suunnatuilla prosessoreilla välimuistin koko vaihtelee välillä 512 kilotavua - 2 megatavua (2048 kilotavua).
Testatakseni välimuistin toimintaa, tein seuraavanlaiset koodinpätkät:
for (int y=0; y<TABLE_DIMENSION; y++)
{
for (int x=0; x<TABLE_DIMENSION; x++)
{
tulos+=data[y*TABLE_DIMENSION+x];
}
}
for (int x=0; x<TABLE_DIMENSION; x++)
{
for (int y=0; y<TABLE_DIMENSION; y++)
{
tulos+=data[y*TABLE_DIMENSION+x];
}
}
Ensimmäinen koodinpätkä käy taulukon läpi käyttäen muistia mahdollisimman lineaarisesti (peräkkäin). Näin tämä koodinpätkä hyödyntää mahdollisimman hyvin periaatteen, jonka mukaan tieto ladataan lineaarisesti (peräkkäisistä muistiosoitteista) välimuistiin. Toinen koodinpätkä taas käyttää tietoa mahdollisimman epälineaarisesti. Luetut muistialkiot eivät ole lähelläkään toisiaan. Huomioi, että kumpikin algoritmi on aikavaativuudeltaan identtinen ja sisältää täsmälleen samat käskyt.
Tuloksia eri kokoisilla taulukoilla:
[DEFTABLE]
[D]Taulukon koko[/D][E]Algoritmin A suoritusaika (ms) - Algoritmin B suoritusaika (ms)[/E]
[D]250*250[/D][E]6 - 7[/E]
[D]500*500[/D][E]41 - 49[/E]
[D]1000*1000[/D][E]170 - 340[/E]
[D]2000*2000[/D][E]660 - 1760[/E]
[D]5000*5000[/D][E]2640 - 30430[/E]
[/DEFTABLE]
Taulukosta nähdään, että algoritmi A suoritusaika on lähes lineaarinen suhteessa alkioiden määrään. Algoritmin B suoritukseen kuluva aika nousee radikaalisti heti kun taulukko ei mahdu enää kokonaisuudessaan välimuistiin (256 kilotavua). Mitä suuremmaksi taulukko kasvaa, sitä enemmän algoritmi A hyötyy lineaarisesta muistinkäytöstään.
Voimme vetää tästä seuraavanlaisen johtopäätöksen: Datan hakeminen välimuistista on yhtä nopeaa lineaarisesti ja satunnaisesti. Datan hakeminen muistista on huomattavasti nopeampaa lineaarisesti. Jos algoritmisi käyttää dataa satunnaisesti (epälineaarisesti) on huomattava etu saada algoritmin käyttämä data mahtumaan kokonaisuudessaan välimuistiin. Jos algoritmisi käyttää dataa lineaarisesti, ei ole niinkään suurta merkitystä käytetyn datan määrällä.
Huomaamme tuloksista, että optimoimalla muistinkäyttö lineaarisemmaksi voidaan saavuttaa suhteellisen pienilläkin datamäärillä yli 10 kertainen teholisä. Samalla huomaamme, että satunnaisesti muistia käyttävä algoritmi nopeutuu yli 10 kertaisesti, jos sen käyttämä data saadaan mahtumaan kokonaisuudessaan välimuistiin. Saavutettu suorituskykylisä on huomattava. Voidaankin sanoa, että nykyarkkitehtuureilla muistioptimoinnit ovat yksi tärkeimmistä optimointikohteista.
Edellisessä kappaleessa puhuimme lookup-taulukoista. Lookup-taulukoiden avulla voidaan muuttaa raskaat käskyt alkion lukemiseksi taulukosta. Lookup esimerkkimme sini/cosini aputaulukko oli 64 alkion kokoinen. Kun yksi alkio vie 4 tavua (liukuluku) on taulukon muistinvienti 256 tavua. Tämä taulukko mahtuu helposti kokonaisuudessaan välimuistiin, joten siitä voidaan hyvin mielin tehdä satunnaisia hakuja (joita sinit ja kosinit yleensä ovat). Lookup-taulukoista muodostuu hyvin helposti kuitenkin niin suuria, että ne eivät mahdu kokonaisuudessaan välimuistiin. Kun tälläisestä taulukosta tehdään satunnaisia hakuja, kärsii suorituskyky huomattavasti. Nykyprosessorit ovat huomattavan nopeita laskemaan monimutkaisiakin matemaattisia operaatioita. Usein on järkevämpää laskea operaatio, kuin käyttää suurehkoa lookup-taulukkoa. Algoritmin muistinkulutusta laskettaessa on otettava huomioon myös algoritmin itsessään käyttämä muisti, ei vain lookup-taulukoiden käyttämää muistia. Lookup-taulukoiden ylikäytöllä välimuistin kantokyky helposti ylitetään, ja suorituskyky romahtaa.
Yhteenvetona: Muistioptimointi on yksi tärkeimmistä optimoinneista nykyarkkitehtuureilla. Mitä pienemmän määrän tietoa algoritmi käyttää sitä lähemmäs prosessoria tieto saadaan haettua. Tärkein rajapyykki on suorittimen välimuisti (128k - 512k). Jos kaikki algoritmin tarvitsema tieto ei mahdu suorittimen välimuistiin, joudutaan tieto hakemaan keskusmuistista. Automaattinen ennakoiva tiedonhaku hakee tietoa välimuistiin käytettyjen muistipaikkojen läheisyydestä. Tämän takia on tärkeää, että tietoa käytetään mahdollisimman lineaarisesti (peräkkäisistä muistipaikoista). Jos haluttua tietoa ei ole ladattu välimuistiin, joutuu prosessori odottamaan sen lataamista keskusmuistista.
6.5 Branch predictor
Nykyprosessorit suorittavat useita käskyjä yhtäaikaa. Käskyt lajitellaan niin, että aluksi suoritetaan käskyt, joita toiset käskyt tarvitsevat syötteinään (jne. rekursiivisesti). Näin järjestelemällä käskyt, prosessori voi olla varma, että käskyn tarvitsemat syötteet ovat aina valmiita kun se niitä tarvitsee. Tämä järjestely johtaa kuitenkin helposti siihen, että osa prosessorin suoritusliukuhihnojen käyttöaste on alhainen. Joissain tapauksissa on järkevämpää arvata tulos, kun odottaa sitä.
Branch predictor arvailee vertailujen (if) tuloksia. Vertailujen arvailu on helppoa, koska tuloksia on vain kaksi erilaista (true, false). Huonoimmillaankin (ilman mitään erikoislogiikkaa) arvattaisiin puolet kerroista oikein. Nykyisten prosessorien kehittyneet algoritmit arvaavat hyvin tehdyn ohjelmakoodin vertailuista yli 90% oikein.
Peruslogiikka arvauksissa perustuu siihen, että vertailun tulos on usein sama kuin edellisellä kerralla. Näinhän on esimerkiksi kaikissa vähänkään pidemmissä silmukoissa (simukan jatkamiseen käytetään vertailua).
Jos prosessori arvaa väärin, joutuu se tyhjentämään liukuhihnansa ja suorittamaan käskyt uudelleen. Tämä operaatio on huomattavan kallis, ja näinollen kannattaakin panostaa koodin, jossa vääriä arvauksia tulee mahdollisimman vähän.
Optimointi branch predictoria varten onnistuu yksinkertaisesti tekemällä ohjelmakoodista helposti arvattavaa. Mitä useammin vertailut palauttavat toisteisia arvoja sitä parempi. Helpoin tapa optimoida branch predictoria varten on vähentää vertailujen määrää aikakriittisista kohdista. Tämä onnistuu esimerkiksi muuntamalla käytetty tieto etukäteen sellaiseen muotoon, jota voidaan käyttää ilman vertailuja (esim RLE-tyylinen pakkaus).
Testeissäni testasin kahta ohjelmakoodia, joista toisessa vertailun tulos oli oikein ja väärin vuorotellen, ja toisessa aina joko oikein tai väärin. Suoritettavien käskyjen määrä oli molemmissa identtinen. Versio, jossa vertailun tulos oli arvattavampi, oli 15% nopeampi. Nopeusero ei ole siis mitenkään valtava. Kannattaa kuitenkin huomioida, että uudemmilla Pentium 4-pohjaisilla laitteistoilla liukuhihnan pituus on jopa 4 kertaa pidempi, kuin minun Athlon Thunderbirdilläni. Näillä laitteistoilla jokainen väärin arvattu vertailu maksaa huomattavasti enemmän. Tulevaisuudessa tämän tyyppiset optimoinnit tulevat olemaan selvästi tärkeämpiä, koska prosessorien liukuhihnojen pidentäminen näyttää olevan nykyinen trendi.
Yhteenvetona: Brach predictorin tehtävänä on vertailujen tuloksien arvaaminen. Mitä arvattavampia (toisteisempia) ohjelmakoodin vertailut ovat, sitä vähemmän vääriä arvauksia tulee. Helpoiten optimointi onnistuu poistamalla huonosti ennustettavia vertailuja aikakriittisistä kohdista, tai korvaamalla ne paremmin ennustettavilla vertailuilla.
7. Yhteenveto #
Tärkeintä optimoinnissa on tietää mitä optimoida ja milloin. Optimointi kannattaa aloittaa vasta kun ohjelmakoodi on valmista. Aloita tietorakenteiden optimoinnista, ja siirry siitä alemmaksi ja alemmaksi. On tärkeää, että optimoinnit eivät rampauta ohjelman rakennetta, eivätkä ne näy ulospäin (vaikeuttaen käytettävyyttä). Hyvä optimoitu koodi suorittaa identtisesti sille annetun tehtävän verrattuna optimoimattomaan koodiin.
Optimointitekniikoita on lukuisia erilaisia. Minulle tärkeitä ovat olleet: algoritmien optimointi, aikakriittisyystasojen optimointi, käskyjen optimointi, lookup-taulukot, sekä muistioptimoinnit. Näillä optimoinneilla saadaan vältettyä laitteiston pullonkaulat, ja otettua siitä viimeisetkin tehot irti.
