Optimalisering av flyttallsintensive programmer. Bjørn Lande



Like dokumenter
Innhold. Virtuelt minne. Paging i mer detalj. Felles rammeverk for hukommelseshierarki Hukommelseshierarki-2 1

Dagens temaer. Kort repetisjon. Mer om cache (1) Mer om cache (2) Read hit. Read miss. Write hit. Hurtig minne. Cache

Tildeling av minne til prosesser

Tildeling av minne til prosesser

INF2270. Minnehierarki

Dagens tema. Flere teknikker for å øke hastigheten

Dagens temaer. Fra kapittel 4 i Computer Organisation and Architecture. Kort om hurtigminne (RAM) Organisering av CPU: von Neuman-modellen

Fakultet for informasjonsteknologi, Oppgave 1 Flervalgsspørsmål ( multiple choice ) 15 %

ytelsen til hukommelseshierarkier

Hukommelseshierarki. 16/3 cache /3 virtuell hukommelse in 147, våren 1999 hukommelseshierarki 1

Oppsummering av digitalteknikkdelen

dynamiske data dynamiske data statiske data program dynamiske data statiske data reservert program dynamiske data statiske data reservert program

Kanter, kanter, mange mangekanter

En oppsummering (og litt som står igjen)

Dagens temaer. Dagens emner er hentet fra Englander kapittel 11 (side ) Repetisjon av viktige emner i CPU-design.

Innhold. Oversikt over hukommelseshierakiet. Ulike typer minne. Innledning til cache. Konstruksjon av cache Hukommelseshierarki-1 1

D: Ingen trykte eller håndskrevne hjelpemiddel tillatt. Bestemt, enkel kalkulator tillatt.

Husk at du skal ha to vinduer åpne. Det ene er 'Python Shell' og det andre er for å skrive kode i.

Oppgave 1 Flervalgsspørsmål ( multiple choice ) 15 %

TDT4160 Datamaskiner Grunnkurs Gunnar Tufte

IN1020. Minnehierarki

Kompleksitetsanalyse Helge Hafting Opphavsrett: Forfatter og Stiftelsen TISIP Lærestoffet er utviklet for faget LO117D Algoritmiske metoder

Fakultet for informasjonsteknologi, Oppgave 1 Flervalgsspørsmål ( multiple choice ) 15 %

Forelesning Instruksjonstyper Kap 5.5

Innhold. 2 Kompilatorer. 3 Datamaskiner og tallsystemer. 4 Oppsummering. 1 Skjerm (monitor) 2 Hovedkort (motherboard) 3 Prosessor (CPU)

INF2270. Datamaskin Arkitektur

Kapittel 7, Minne RAM DIMM, SIMM ROM, PROM, EPROM, EEPROM FLASH DIM SUM. Cache Virtuelt minne

AVSLUTTENDE EKSAMEN I. TDT4160 Datamaskiner Grunnkurs Løsningsforslag. Torsdag 29. November 2007 Kl

IN1020. Datamaskinarkitektur

AVSLUTTENDE EKSAMEN I. TDT4160 Datamaskiner Grunnkurs. Torsdag 29. November 2007 Kl

EKSAMEN I TDT4160 DATAMASKINER GRUNNKURS

TDT4102 Prosedyre og Objektorientert programmering Vår 2014

Dagens temaer. Virtuell hukommelse (kapittel 9.9 i læreboken) Pentium-arkitekturen i mer detalj INF 1070

ADDISJON FRA A TIL Å

Forelesning 5. Diverse komponenter/større system

Oppgave 1 JK-flip-flop (Total vekt 20%)

Dagens temaer. Virtuell hukommelse. Sidetabell. Virtuell hukommelse (forts.)

Overordnet maskinarkitektur. Maskinarkitektur zoomet inn. I CPU: Kontrollenheten (CU) IT1101 Informatikk basisfag, dobbeltime 11/9

Obligatorisk oppgave 1: Regneklynge

Dagems temaer. kapittel 4 i Computer Organisation and Architecture. av CPU: von Neuman-modellen. Transfer Language (RTL) om hurtigminne (RAM)

bruksområder og oppbygging om cache-hukommelse (kapittel 6.5 i Computer Organisation Dagens temaer and Architecture ) ROM RAM

hvor mye hurtigminne (RAM) CPU en kan nyttiggjøre seg av. mens bit ene betraktet under ett kalles vanligvis et ord.

Intel Core i7. Omid Mirmotahari 4

Posisjonsystemet FRA A TIL Å

Cache (repetisjon) Cache (repetisjon) Cache (repetisjon) Dagens temaer. CPU Cache RAM. om cache-hukommelse (kapittel 6.5 i Computer Organisation

NOTAT (pensum!) Javas klasse-filer, byte-kode og utførelse. INF 5110, 10/5-2011, Stein Krogdahl

TDT4160 Datamaskiner Grunnkurs Gunnar Tufte

Dagens temaer. Cache (repetisjon) Cache (repetisjon) Cache (repetisjon)

Dagens temaer. Mer om cache-hukommelse (kapittel 6.5 i Computer Organisation and Architecture ) RAM ROM. Hukommelsesbusser

TDT4160 Datamaskiner Grunnkurs Gunnar Tufte

kan adressere et større område som RAM enn det som faktisk er tilgjengelig. Siden data kan plasseres i RAM og/eller på harddisken brukes begrepet

Oppsummering Assemblerkode Hopp Multiplikasjon Kode og data Array Oppsummering

EKSAMEN I TDT4160 DATAMASKINER GRUNNKURS

INF1400 Kap4rest Kombinatorisk Logikk

Pensum Hovedtanker Selvmodifiserende Overflyt Veien videre Eksamen. Oppsummering

INF2270. Datamaskin Arkitektur

NOTAT (pensum!) Javas klasse-filer, byte-kode og utførelse

Litt om Javas class-filer og byte-kode

Tildeling av minne til prosesser

Operativsystemer og nettverk Løsningsforslag til eksamen Oppgave 1. a) Linux-kommando: java Beregn & b) Shellprogram:

Den siste dagen. Pensumoversikt Hovedtanker i kurset Selvmodifiserende kode Overflyt Veien videre... Eksamen

Seksjon 1. INF2270-V16 Forside. Eksamen INF2270. Dato 1. juni 2016 Tid Alle trykte og skrevne hjelpemidler, og en kalkulator, er tillatt.

Forelesning Datatyper Kap 5.2 Instruksjonsformat Kap 5.3 Flyttall App B

TDT4160 OG IT2201 DATAMASKINER GRUNNKURS EKSAMEN

UNIVERSITETET I OSLO

Innhold. Oppgave 1 Oversettelse (vekt 15%)

Utførelse av programmer, metoder og synlighet av variabler i JSP

Forelesning Hurtigbuffer Kap 4.5

Eneboerspillet del 2. Håvard Johnsbråten, januar 2014

Dagems temaer INF ! Fra kapittel 4 i Computer Organisation and Architecture. ! Kort om hurtigminne (RAM)

Internminnet. Håkon Tolsby Håkon Tolsby

Prosessoren. Bakgrunnen Innhold LMC. Assemblerkode Oppsummering instruksjonene [Englander kap 6] Hva inneholder den? Hvordan utføres instruksjonene?

Datamaskinens oppbygning

Dagens temaer. Dagens emner er hentet fra Englander kapittel 10 (side ) Mer om adresseringsmodi. RISC og CISC-prosessorer.

Innhold. Introduksjon til parallelle datamaskiner. Ulike typer parallelle arkitekturer. Prinsipper for synkronisering av felles hukommelse

INF oktober Dagens tema: Uavgjørbarhet. Neste uke: NP-kompletthet

Forelesning Forgreningspredikering Kap 4.5 Superskalaritet Kap 4.5 Spekulativ utføring Kap 4.5

Kapittel 3: Litt om representasjon av tall

Oppgave 1 - Linux kommandolinje (%)

Runtime-omgivelser Kap 7 - I

Forelesning Optimalisering av μark Kap 4.4

Oppgave 8.1 fra COD2e

TDT4258 Eksamen vår 2013

Internminnet. Håkon Tolsby Håkon Tolsby

EKSAMENSOPPGAVE, INF-2200

Generelt om operativsystemer

Håndtering av minne i et OS

Dagens temaer. Mer om adresseringsmodi. Indeksert adressering med offset og auto-inkrement eller dekrement. Register-indirekte adressering

! Ytelsen til I/O- systemer avhenger av flere faktorer: ! De to viktigste parametrene for ytelse til I/O er:

Tallinjen FRA A TIL Å

Forelesning ISA-nivået Kap 5.1

UNIVERSITETET I OSLO

Pensumoversikt - kodegenerering. Kap. 8 del 1 kodegenerering INF5110 v2006. Hvordan er instruksjonene i en virkelig CPU? Arne Maus, Ifi UiO

Runtimesystemer Kap 7 - I

TDT4225 Lagring og behandling av store datamengder

Oppbygningen av en datamaskin Det viktigste i en moderne datamaskin er hovedkortet («motherboard»):

LITT OM OPPLEGGET. INF1000 EKSTRATILBUD Stoff fra uke September 2012 Siri Moe Jensen EKSEMPLER

Oppgave 2 Maskinkode (vekt 12%)

! Sentrale begreper er adresserbarhet og adresserom. ! Adresserbarhet: Antall bit som prosessoren kan tak samtidig i én operasjon

hukommelse (kapittel 9.9 i læreboken) Dagens temaer Input-Output INF 1070

Transkript:

Optimalisering av flyttallsintensive programmer Bjørn Lande Institutt for matematiske fag NTNU 2004

Forord Denne rapporten ble opprinnelig skrevet som en del av et prosjektarbeide i sivilingeniørstudiet ved NTNU. Dette er en utgave som har blitt omarbeidet og tilrettelagt for bruk i faget TMA4280 Superdatamaskiner. Takk til Einar M. Rønquist ved Institutt for matematiske fag, som var veileder i prosjektarbeidet, og som ga gode forslag til endringer i forhold til den opprinnelige rapporten. I dette arbeidet benyttet jeg meg av tungregningsressursene til NOTUR. Jeg vil også takke for hjelp jeg fikk av deres support-ansvarlige for å kunne benytte meg av programpakkene for ytelsesmåling. Trondheim, 3. november 2004 Bjørn Lande iii

iv

Innhold Forord iii 1 Innledning 1 2 Datamaskinarkitektur 2 2.1 Motivasjon............................ 2 2.2 En enkel introduksjon...................... 2 2.3 Prosessorkjerne.......................... 4 2.4 Teoretisk og oppnåelig ytelse.................. 7 2.5 Minnehierarki........................... 7 2.6 Cache............................... 10 2.7 Virtuelt minne.......................... 14 2.8 Oppsummering.......................... 18 3 Optimalisering av daxpy 20 3.1 Generelle optimaliseringsnivå.................. 20 3.2 Problembeskrivelse........................ 21 3.3 Program og eksperiment..................... 21 3.4 Grunnleggende temaer...................... 22 3.5 Videregående temaer....................... 26 3.6 Oppsummering.......................... 34 4 Optimalisering av matrisemultiplikasjon 35 4.1 Problembeskrivelse........................ 35 4.2 Løkketransformasjoner...................... 36 4.3 Ulike strategier for implementasjon............... 41 4.4 Variasjoner i oppnådd ytelse................... 43 4.5 Oppsummering.......................... 43 5 Nyere trender 47 5.1 Prosessor- og minneteknologier................. 47 5.2 Optimaliseringsteknikker..................... 51 6 Konklusjon 53 Referanser 55 A MIPSpro 7 kompilatorsystem 56 A.1 Motivasjon............................ 56 v

A.2 Oversikt over kompilatoren................... 56 A.3 Moduler og faser......................... 58 B Tidsmåling og ytelsesverktøy 62 B.1 Manuell tidsmåling........................ 62 B.2 Prosessorens event-tellere.................... 64 B.3 Profilering av et program.................... 66 C Løkketransformering av kode 70 C.1 Opprinnelig kode......................... 70 C.2 Endring av løkkerekkefølge.................... 71 C.3 Utrulling av ytre løkker..................... 72 C.4 Blokking av cache......................... 74 C.5 Fullstendig transformasjon.................... 75 C.6 Manuell utrulling......................... 78 vi

Figurer 1 En enkel prosessor........................ 3 2 Pipelining i MIPS R14000.................... 5 3 Et minnehierarki......................... 8 4 2-way set-associative cache.................... 12 5 Adresseoversettelse for virtuelt minne.............. 15 6 Page Table............................ 16 7 Ytelse for daxpy, -O1 og -O2.................. 23 8 Software pipelining av daxpy.................. 27 9 Ytelse for daxpy, -O3 og BLAS................. 32 10 Ytelse for daxpy, -Ofast..................... 33 11 Ytelse for mxm ved ulike optimaliseringsnivåer........ 44 12 Ytelse for mxm ved ulike strategier............... 45 13 MIPSpro kompilatorsystem................... 57 vii

viii

1 Innledning Moderne datateknologi gir oss stadig hurtigere datamaskiner, som også kan løse større numeriske problemer enn før. Det kan synes som om det er skrevet lite litteratur som beskriver hvordan numeriske programmer faktisk benytter seg av maskinressursene, og hvilke teknikker som tas i bruk bak scenen for å oppnå høy ytelse. Det som finnes er ofte lengre tekniske manualer som er skrevet fra et datateknisk ståsted. Målet med denne rapporten har vært å filtrere dette stoffet til noe som kan leses av en som først og fremst har bakgrunn fra numerisk matematikk. Rapporten er avgrenset til å handle om enkeltprosessorer. Optimalisering av parallelle beregninger er et stort tema i seg selv, og må uansett omfavne optimalisering for den enkelte prosessor. Vi begynner i seksjon 2 med en oversikt over hvordan en datamaskin utfører et program og de enkelte delene en prosessor består av. Vi forklarer hvordan minnesystemet i datamaskinen er bygd opp. Det er lagt vekt på de temaene som har betydning for flyttallsberegninger på store datamengder. I seksjon 3 drøfter vi noen generelle teknikker som det er vanlig å bruke for å bedre ytelsen. Vi bruker en enkel vektoroperasjon fra lineær algebra, daxpy, til å demonstrere hvordan disse har betydning for numeriske programmer. Denne diskusjonen fortsetter i seksjon 4, men om temaer som spesielt er relevante for store datamengder og mer kompliserte programstrukturer som nøstede beregningsløkker. Her bruker vi matrisemultiplikasjon som eksempel, og vi viser hvordan vi kan oppnå spesielt høy ytelse for denne beregningen. Det pågår stadige endringer i utviklingen av både prosessorer og minne. Nyere optimaliseringsteknikker for lineær algebra må ta hensyn til dette. Vi gir en oversikt over noen slike tema i seksjon 5. I arbeidet med rapporten brukte vi kompilator- og profileringsverktøy. Vi nevner noen av disse underveis, og forklarer mer om dem i appendiks. 1

2 Datamaskinarkitektur 2.1 Motivasjon Det er kanskje ikke opplagt hvorfor det er spesielt viktig å vite hvordan de ulike delene i en datamaskin fungerer sammen og hver for seg. De vanligste programmeringsspråkene som brukes innen numeriske beregninger i dag, er høy-nivå språk som C, C++ og Fortran. En fordel med slike språk er jo at de skjuler mye av datamaskinens kompleksitet, til fordel for en ganske enkel og abstrakt modell. Programmereren kan konsentrere seg om å velge en passende numerisk metode og implementere denne. Kompilatoren får så ansvaret for å generere den faktiske programkoden som prosessoren skal kjøre. Dessverre er samspillet mellom de ulike delene av datamaskinen såpass komplisert at kompilatoren ikke alltid greier å produsere kode som er god nok. Dessuten kan valg som programmereren har foretatt, gjøre jobben til kompilatoren enda vanskeligere. Numeriske programmer er gjerne beregningsintensive, og de bruker ofte mye minne pga. store datamengder som skal behandles. Hvis flaskehalser oppstår i prosessor eller minne, vil ytelsen til programmet falle betraktelig fra det som ellers kunne vært mulig. I mange tilfeller er ikke programmereren engang klar over denne forskjellen mellom oppnådd og teoretisk ytelse. Programmereren burde ha gode muligheter til å forbedre ytelsen. Det finnes verktøy som kan måle hva slags problemer et program (eller de enkelte rutinene) har, og hvor store problemene er. Ved å endre datastrukturer eller andre deler av programmet kan noen problemer unngås. Det finnes dessuten et ganske stort spillerom gjennom ulike opsjoner som kan brukes ved kompileringen. For å kunne bruke disse mulighetene, er det nødvendig med noen datatekniske kunnskaper. 2.2 En enkel introduksjon Vi begynner med en oversikt over en typisk prosessor, noen av dens enheter og hvilken kontakt den har med resten av datamaskinen. Som modell har vi brukt prosessoren MIPS R14000 [17, 8]. Datamaskinene Gridur og Embla ved NOTUR, som er av typen SGI Origin 3000, benytter denne prosessoren. En forløper for R14000 var R2000, og dens oppbygning og virkemåte er fortsatt relevant for moderne prosessorer. F.eks. bruker [14] R2000 til å ta 2

for seg de generelle temaene innen datamaskinarkitektur på en omfattende, men likevel relativt enkel måte. Andre generelle kilder er [7, 6]. Under følger vi et programs instruksjoner i de ulike fasene som de går gjennom, indikert ved nummereringen på Figur 1. System Bus to RAM, disk, network 8 7 4 Branch L1 instruction cache 2 3 Decode L1 data cache 6 Integer 5 Load/Store L2 cache Floating Point 1 Clocks Figur 1: En enkel prosessor. Enhetene som er vist her er felles for moderne prosessorer. Det er likevel stor spennvidde i implementasjon, og antall, av hver type enhet. Figuren er en forenkling av figurer i [6, 8, 18]. 1. En klokke definerer en tidsperiode eller sykel. Når en sykel er omme, fortsetter arbeidet i neste fase. De forskjellige delene i prosessoren arbeider uavhengig av hverandre med instruksjoner som er i ulik fase. 2. I løpet av hver sykel hentes nye instruksjoner sekvensielt fra L1 instruction cache. 3. Instruksjonene blir dekodet, dvs. forberedt for utførelse. 4. Branch-instruksjoner blir tatt til side. Avgjørelser om å endre instruksjonsflyten, f.eks. i løkker og if-then-konstruksjoner, tas på bakgrunn av betingelser som disse instruksjonene definerer. 5. Heltalls-, flyttalls-, og minneinstruksjoner blir startet opp hver for seg. 3

For hver av de tre typene instruksjoner finnes det flere funksjonelle enheter som ikke er vist her. Det er i disse enhetene selve arbeidet med å beregne resultatene foregår. 6. Data som instruksjonene skal bruke hentes fra L1 data cache til registre, som enhetene også plasserer resultatene i etterpå. Senere lagres resultatene tilbake til cache. 7. Hvis ikke L1 cache har de ønskede instruksjoner eller data, må de hentes fra L2 cache først. 8. Om nødvendig må instruksjoner eller data hentes over systembussen fra hovedminnet, disk eller nettverk. For beregningsintensive programmer, er det viktig at gjennomstrømningen er høy, dvs. at de enkelte delene ikke bruker unødvendig lang tid, og at alle tilgjengelige enheter i størst mulig grad er i bruk. 2.3 Prosessorkjerne Det varierer mellom ulike prosessorer akkurat hvilke funksjonelle enheter det finnes og hvor mange det er av dem. R14000 har fem enheter som arbeider uavhengig av hverandre og har følgende ansvar: Load/Store: beregne minneadresser, bringe dataoperander til og fra minnet ALU1: addisjon, subtraksjon av heltall, logiske operasjoner ALU2: addisjon, subtraksjon, multiplikasjon, divisjon av heltall, logiske operasjoner FPAdd: addisjon av flyttall FPMult: multiplikasjon, divisjon og kvadratrot av flyttall I tillegg finnes det flere enheter for styring av programflyten, disse er vist nede til venstre i Figur 1. 2.3.1 Pipelining Funksjonelle enheter benytter som regel pipelining-prinsippet, som betyr at arbeidet med hver enkelt instruksjon er delt opp i flere mindre steg. Hvert 4

steg tar en klokkesykel å utføre. Figur 2 viser hvordan dette fungerer på R14000. De enkelte stegene er nærmere forklart i [17, 6, 14, 8]. Stage 1 Stage 2 Stage 3 Stage 4 Stage 5 Stage 6 Stage 7 Stage 8 Predecode Fetch Decode Branch Issue FPAdd Read register Align Add Pack Write register FPMult Read register Multiply Sum product Pack Write register ALU1 Read register Execute Write register ALU2 Read register Execute Write register Load/ Store Read register Address calculation Cache access Write register Figur 2: Pipelining i MIPS R14000. Prosessoren kan normalt starte opp arbeidet med en ny instruksjon i en enhet allerede i påfølgende klokkesykel etter at forrige instruksjon ble startet opp. På denne måten kan en funksjonell enhet hele tiden arbeide med flere instruksjoner i forskjellig grad av fullførelse. Prosessoren har et buffer for dekodede instruksjoner, bestående av tre instruksjonskøer, for heltalls-, flyttalls- og minneinstruksjoner. Instruksjonene står på vent mens operandene blir beregnet eller hentet inn fra minnet. Figuren bygger på tilsvarende i [17, 6, 8]. Det varierer mellom enhetene, og hvilke instruksjoner de utfører, hvor mange steg (og klokkesykler) hele arbeidet tar. Hvis et resultat skal brukes videre i en annen instruksjon, kan ikke denne startes opp før resultatet er ferdig beregnet. 5

2.3.2 Superskalare egenskaper Moderne prosessorer er superskalare, dvs. at de hver klokkesykel kan starte opp utførelsen av flere instruksjoner om gangen. R14000 kan sette i gang arbeidet med opp til fire instruksjoner per klokkesykel, og kalles derfor 4-veis superskalar. Til enhver tid kan opp til 48 dekodete instruksjoner i forskjellig grad av utførelse være underveis i prosessoren. Prosessoren står fritt til å starte opp instruksjoner, utenom programrekkefølgen, så snart en enhet er ledig, og operandene er klare. Dette kalles dynamic scheduling. Slik out-of-order programutførelse er en av teknikkene som brukes for å øke gjennomstrømningen av instruksjoner i prosessoren. Men et resultat er ikke endelig før foregående instruksjoner også er ferdigbehandlet. Prosessorens muligheter til å endre på den oppsatte instruksjonsrekkefølgen vil alltid være begrenset. Hvis alle enhetene skal brukes på best mulig måte, er det viktig at kompilatoren har laget en instruksjonsrekkefølge som ikke hindrer dette. 2.3.3 Registre Prosessoren har som nevnt et visst antall registre, som brukes av de funksjonelle enhetene. Operander kan bare leses herfra, og de må først hentes inn fra minnet med en load-operasjon. Etter at en beregning er ferdig, skrives resultatet til et register igjen. Etterpå blir det gjerne skrevet tilbake til minnet med en store-operasjon. For numeriske programmer er det viktig å benytte registrene til mellomlagring av resultater. Da blir behovet for minnetrafikk mindre. Det finnes 64 heltallsregistre og 64 flyttallsregistre, som alle er 64 bit lange. Dette er de fysiske registrene. Kompilatoren vet bare om 32 registre av hver type. Disse kalles logiske registre. Til enhver tid kan det være flere instruksjoner i forskjellig fase av utførelse som bruker et bestemt logisk register. Derfor fordeler prosessoren de logiske registrene på de enhver tid ledige fysiske registrene. Dette kalles register renaming. 6

2.4 Teoretisk og oppnåelig ytelse I lineær algebra er det svært vanlig å multiplisere to flyttall og addere resultatet sammen med et tredje, en operasjon som kalles multiply-add. MIPS har en egen instruksjon (madd) som samkjører de to flyttallsenhetene slik at arbeidet tar mindre tid, og et nytt resultat kan være ferdig hver eneste klokkesykel. Siden dette resultatet stammer fra to beregninger, vil den teoretiske toppytelsen i MFlops 1 for R14000 være det dobbelte av klokkehastigheten. Prosessorene på Gridur og Embla har klokkehastigheter på hhv. 500 og 600 MHz. Den teoretiske toppytelsen for hver enkelt prosessor blir derfor hhv. 1 og 1.2 GFlops. Det er for de fleste beregninger ikke mulig å nå denne ytelsen. Divisjon og kvadratrot av flyttall er svært tidkrevende operasjoner som kan ødelegge ytelsen. Det samme kan sies om multiplikasjon og divisjon av heltall. En annen begrensning er minnet, som vi forklarer i følgende seksjon. 2.5 Minnehierarki Som vist i Figur 1, består ikke minnet i datamaskinen av en enkelt enhet. Det optimale ville være å ha en ubegrenset mengde minne, som også var svært hurtig. Av fysiske grunner er dette er ikke mulig. I stedet brukes et minnehierarki som kan skape en god erstatning. I et minnehierarki finnes det flere nivåer med minne, der hvert nivå har ulik størrelse og hurtighet. Vi viser et minnehierarki i Figur 3. Detaljene i hvor mange nivåer det finnes og størrelsen på hvert av dem, samt minneteknologi som benyttes, varierer mellom ulike datamaskiner. Disse valgene avgjøres på grunnlag av prosessoregenskaper og bruksområder, men også markedshensyn. Det varierer også hvor mange av nivåene som befinner seg på selve prosessoren, og hvilke som ligger utenfor. 2.5.1 Lokalitetsprinsippet Årsaken til at det er mulig å bruke et minnehierarki finner vi i lokalitetsprinsippet. Det finnes to typer lokalitet. 1 Million Floating Point Operations per Second. 7

Prosessorkjerne Nivåer i minnehierarkiet L1 L2 Økende avstand fra prosessoren i aksesstid... Ln Størrelsen på minnet på hvert nivå Figur 3: Et minnehierarki. Mot toppen av hierarkiet brukes stadig hurtigere og dyrere minneteknologier, og størrelsene blir derfor små. På lavere nivåer brukes ikke like hurtige, men også langt billigere teknologier, slik at størrelsene på nivåene kan være store. I de fleste systemer må elementer som befinner seg på et nivå i også befinne seg på nivå i + 1. Hvis prosessoren kan hente data eller instruksjoner fra de øverste nivåene, er aksesstiden lav, og båndbredden høy. Derimot vil aksesstiden stige nedover i hierarkiet, samtidig som båndbredden synker. Et vanlig problem for minneintensive programmer er at prosessoren stadig vekk må hente data fra nivåer langt nede, slik at ytelsen reduseres. Da blir prosessoren stående på vent mens data hentes inn. Figuren er hentet fra [14]. 8

Temporal lokalitet (dvs. i tid) betyr at når en instruksjon eller et dataelement blir referert, er det høy sannsynlighet for at det vil bli referert igjen i nær framtid. Romlig lokalitet betyr at når en instruksjon eller et dataelement blir referert, er det høy sannsynlighet for at andre instruksjoner eller data som ligger i nærheten også vil bli referert i nær framtid. 2.5.2 Lokalitet i et numerisk program En løkkekonstruksjon som utfører en numerisk beregning på vektorer eller matriser vil typisk utvise høy grad av lokalitet. De samme instruksjonene, og ofte også dataelementene, vil bli brukt gjentatte ganger med kort mellomrom. Dette er temporal lokalitet. Instruksjonene utføres (normalt) sekvensielt. For hver gjennomløpning brukes elementer i datastrukturene som ligger like ved de som ble brukt forrige gang. Derfor utvises det også romlig lokalitet. De instruksjoner og data som et program i løpet av en tidsperiode benytter seg av, kalles gjerne for et working set. Pga. lokalitet vil dette settet over tid endre seg relativt sakte. Det er en fordel om settet til enhver tid ikke er så stort at det medfører mye trafikk mellom minnenivåene. 2.5.3 Et minnehierarki i praksis I en moderne datamaskin består et typisk minnehierarki av hovedminne, cache og harddisk. Hovedminnet, som er et nivå midt i hierarkiet, er relativt hurtig, og er ment å romme program- og datastrukturer som brukes i programkjøringen. Størrelsen på hovedminnet kan variere fra noen hundre MB til flere GB. På SGI Origin 3000 brukes en organisering der fire prosessorer deler på opp til 4GB. 2 Harddisk, som er et av de laveste nivåene, er langt tregere, og er ment å lagre inngangsdata og ferdigbehandlede resultater. Størrelsen kan her variere mellom hundre GB og flere TB. Hvis programmet behandler store datamengder, kan det bli nødvendig å benytte harddisken for å lagre deler av hovedminnet. Det er dermed harddiskteknologi som sørger for å nå målet om et tilnærmet ubegrenset minne. Vi forklarer hvordan dette foregår i 2.7. 2 Dette er en forkenkling, parallelle program kan benytte seg av delt minne (NUMA). 9

De siste årene har prosessorer blitt langt raskere enn de var før, men samtidig har ikke teknologien for hovedminne (dynamisk RAM) greid å holde følge. Gapet mellom disse to delene av datamaskinen har derfor blitt stadig større. Derfor finnes det flere nivåer mellom prosessor og hovedminnet bestående av hurtigere minneteknologier (statisk RAM). Disse nivåene, som blir de øverste i minnehierarkiet, kalles cache. Det er cache som sørger for at målet om et svært hurtig minne oppnås. Vi drøfter cache i 2.6. 2.5.4 Adressering av minnet Minnet kan noe forenklet ses på som en endimensjonal liste av dataelementer. Hvert element har sin egen adresse. For å hente et element fra minnet og plassere det i et register, må prosessoren først beregne den riktige adressen. Dette gjøres vha. en base-adresse og en offset. Når et element x[i] i en array skal aksesseres, er x en peker som angir base-adressen, mens indeksen i fungerer som offset. Load/Store-enheten leser disse verdiene fra registre og adderer dem, slik at adressen til elementet blir funnet. 3 Deretter brukes denne adressen til å finne elementet et sted i minnehierarkiet. 2.6 Cache De fleste datamaskiner har i dag to cache-nivåer, hhv. L1 og L2 cache. 4 I tillegg er det vanlig at det øverste og raskeste nivået, L1 cache, er delt i et cache kun for instruksjoner og et annet kun for data. Derimot er som regel L2 cache felles for både instruksjoner og data. R14000 følger denne organiseringen, og har 32kB for hver av de to delene av L1 cache. L2 cache rommer hele 8MB, tilsvarende for de fleste vanlige datamaskiner er 512kB 2MB. 2.6.1 Cache Hit og Cache Miss Hvis et element som prosessoren ønsker befinner seg i L1 cache, tar det 2 3 klokkesykler før elementet er klart i registret. Dette kalles L1 cache hit. I 3 Dette er en såkalt virtuell adresse, som forklares i 2.7.1. 4 Det finnes også andre typer cache i en datamaskin, bl.a. TLB som blir omtalt senere. 10

motsatt fall oppstår en cache miss. Hvis elementet befinner seg i L2 cache, hentes elementet først til L1 cache, og blir tilgjengelig i registret etter 10 12 sykler. Utførelsen av instruksjonen som forårsaket cache miss stopper inntil data er tilgjengelig. Men R14000 fortsetter å arbeide med andre instruksjoner. Cache kan ha opp til 4 cache miss utestående og likevel oppfylle nye minneaksesser. Cache med denne egenskapen kalles ikke-blokkerende. På denne måten er det mulig å skjule L1 cache miss, tildels også L2 cache miss. 2.6.2 Blokk-oppdeling Data befinner seg i cache i form av blokker. En block (også kalt cache line) består av et lite antall elementer med etterfølgende minneadresser. Hvert nivå i hierarkiet har en bestemt blokkstørrelse som angir den minste grupperingen av elementer som flyttes til eller fra dette nivået. Hensikten med å partisjonere minnet i blokker er å kunne dra fordel av romlig lokalitet, siden elementer som med høy grad av sannsynlighet vil bli brukt sammen, dermed blir liggende etter hverandre i cache. For L1 data cache bruker R14000 en blokkstørrelse på 32 bytes, dvs. 4 flyttall i double precision. Tilsvarende for L2 cache er 128 bytes, altså 16 slike flyttall. 2.6.3 Set-associative cache R14000 har 2-way set-associative cache. Cache består av et antall sett 5. Det er adressen en blokk har i hovedminnet som avgjør hvilket sett den skal plasseres i. Hvert sett har plass til to blokker, og når en ny blokk hentes inn må en av de gamle byttes ut. En Least Recently Used-mekanisme (LRU) velger den som det har gått lengst tid siden ble brukt. Figur 4 illustrerer denne typen cache. Hvis et program bruker to blokker som trenger å bli plassert i samme sett, kan begge befinne seg i cache samtidig. Dette er en fleksibilitet som reduserer cache miss. Men hvis programmet bruker tre eller flere blokker som konkurrerer om å befinne seg i det samme settet, fører dette til dårlig bruk av cache, siden blokker stadig skiftes ut. I verste fall oppstår cache trashing, dvs. ingen gjenbruk av data i cache. 5 Vi bruker her ordet sett i samme betydning som [14], i motsetning til [18]. 11

Set 0 x[0] Cache block Tag Cache block Tag x[1] x[2] x[3] Way 0 Way 1 Set 1 x[4] x[5] x[6] x[7] Set 2 x[8] x[9] x[10] x[11] Set 3 x[12] x[13] x[14] x[15] Set 4 Figur 4: 2-way set-associative cache. Blokkstørrelse her er 32 bytes, som for L1 data cache i R14000. De nedre bit-verdiene i en blokkadresse avgjør hvilket sett blokken blir plassert i. Samtidig lagres de øvre bit-verdiene (som er felles for alle blokkelementene) som en tag. Når det slås opp i cache, brukes denne til å finne ut hvor i minnet blokken kommer fra. Cache bruker LRU-prinsippet til å avgjøre hvor i settet en blokk skal plasseres. Her har elementene (av typen double) x[0]..x[7] og x[12]..x[15] blitt plassert i Way 0, mens x[8]..x[11] har blitt plassert i Way 1. R14000 bruker i L2 cache en blokkstørrelse på 128 bytes, slik at alle disse elementene vil befinne seg i samme blokk. L1 data cache har 512 sett, og det tilsvarende for L2 cache er 32768. Figuren har trekk fra tilsvarende figurer i [17, 18, 14]. 12

Dette er en aktuell problemstilling for numeriske programmer. Ved bruk av arraydimensjoner som er en potens av to, f.eks. n = 2 10 = 1024, er det en fare for at dette oppstår. I så fall kan omorganisering av data kan nødvendig for å oppnå god bruk av cache. 2.6.4 Write-back cache R14000 benytter write-back cache, dvs. at store-operasjoner bare skriver til L1 data cache. Hver gang en blokk i dette nivået må byttes ut med en ny fra L2 cache, blir det kontrollert om den har blitt endret i løpet av den perioden den har befunnet seg der. I så fall lagres disse endringene i L2 cache. Det samme prinsippet gjelder når en blokk fra L2 cache må byttes ut med en ny fra hovedminnet. Alternativet til denne type cache er write-through cache. Da blir alle storeoperasjoner skrevet til både cache og hovedminnet. Hensikten med write-back cache er å redusere minnetrafikken, ved at det kan foretas mange oppdateringer av en blokk uten at dette lagres i hovedminnet. 2.6.5 Valg for cache-organisering Vi har forklart hvordan cache i R14000 bruker et regelverk til å avgjøre en blokks plassering i cache. I 2.7.5 ser vi hvordan oppslag i cache foretas. Det kan gjøres andre valg for organisering. De tre hovedalternativene for organisering av cache er: direct mapped, der en blokk kan plasseres bare ett bestemt sted i cache. set-associative, der en blokk kan plasseres i bare ett bestemt sett i cache, men kan plasseres hvor som helst innenfor dette settet. fully associative, der en blokk kan plasseres hvor som helst i cache. Det første alternativet gjør det enkelt, og dermed hurtig, å finne ut hvor en blokk er plassert i cache. Samtidig fører lite fleksibilitet til større konkurranse om samme plass, mao. flere cache miss. Det siste alternativet gir mest fleksibilitet og færrest cache miss, men krever tidkrevende arbeid for å finne ut hvor i hovedminnet en blokk stammer fra. Mindre spesial-cache bruker gjerne slik organisering. 13

De fleste cache bruker i dag en organisering som ligner den R14000 har. Antall blokker pr. sett kan være høyere, men sjelden over 4 eller 8, siden antallet cache miss ikke reduseres så mye etter dette. Dessuten vil arbeidet med å gjenfinne en blokk ta lengre tid. Størrelse og organisering av L1 cache er som regel optimalisert med tanke på lavest mulig hit time, dvs. tidskostnad for å hente operand til register. Denne kan likevel skjules av ikke-blokkerende cache. L1 cache kan derfor være ganske liten, selv om dette gir økt antall cache miss. For L2 cache vil det være mer aktuelt å optimalisere for færre cache miss, siden en økt tidskostnad for aksess bare vil ha innvirkning de gangene det oppstår miss i L1 cache. 2.7 Virtuelt minne For å integrere hovedminnet og harddisk, bruker datamaskinen en mekanisme som kalles virtuelt minne. 6 Det virtuelle minnet sørger også for mange andre egenskaper, f.eks. at programmer ikke overskriver hverandres minne. Den minste enheten som flyttes mellom hovedminnet og harddisk, kalles for page, eller side. Dette kan sammelignes med bruk av blokkoppdeling av cache, men en side er langt større. For R14000 er størrelsen normalt 16kB, men det er også mulig å la programmer bruke andre verdier. 2.7.1 Virtuelt og fysisk adresserom Når et program refererer adresser som et dataelement skal hentes fra eller skrives til, brukes virtuelle adresser. Disse viser til et virtuelt adresserom, som er delt opp i virtuelle sider med tilhørende sidenummer. Størrelsen på adresserommet er avhengig av om prosessoren er i 32- eller 64-bit modus. I 64-bit modus bruker R14000 44-bit virtuelle adresser, som tilsvarer et adresserom på 16TB. Derimot brukes fysiske adresser for å aksessere et dataelement som befinner seg i hovedminnet. Det fysiske adresserommet bruker en tilsvarende oppdeling i fysiske sider. For R14000 benyttes 40-bit fysiske adresser, som tilsvarer maksimalt 1TB hovedminne. og 5. 6 Denne seksjonen kan gjerne leses senere, men vi bruker noe av stoffet i seksjonene 4 14

2.7.2 Adresseoversettelse Før minnet kan aksesseres, må den virtuelle adressen (som genereres av Load/Store-enheten), oversettes til en tilhørende fysisk adresse. Denne oversettelsen kalles memory mapping. Figur 5 illustrerer hvordan den virtuelle adressen oversettes til en fysisk adresse. 43 42... Virtual address 15 14 13 12... 1 0 Virtual page number Page offset 39 38 Translation... 15 14 13 12... 1 0 Physical page number Page offset Physical address Figur 5: Adresseoversettelse for virtuelt minne. I en virtuell adresse vil de øverste bit-verdiene angi et bestemt virtual page number. Dette oversettes til et tilsvarende physical page number. Den fullstendige fysiske adressen består av det oversatte sidenummeret og de resterende (uoversatte) bit-verdiene. Disse nederste bit-verdiene angir page offset, dvs. hvorhen i en side dataelementet befinner seg. Figuren er hentet fra [14], men med bit-verdier fra [18]. Her er det ikke nødvendig å gi en fullstending beskrivelse av denne oversettelsen. Hovedpoenget er at vi fra en virtuell side får generert en fysisk side, som er en av bestanddelene i den fullstendige fysiske adressen. Av størrelsene på adresserommene ser vi at at antall virtuelle sider er langt høyere enn antall fysiske sider. Dette er grunnlaget for at programmet kan bruke nesten ubegrenset minne, ved at de overskytende sidene befinner seg på harddisk. Dette kalles også disk paging. 15

2.7.3 Page Table og Page Fault Operativsystemet i datamaskinen oppretter en page table for hvert program som utføres. Figur 6 viser hvordan denne tabellen brukes i adresseoversettelsen. Hvis tabellen angir at den ønskede siden befinner seg i hovedminnet, returneres det fysiske sidenummeret. Dette kalles page hit. Hvis tabellen angir at siden befinner seg på disk, oppstår en page fault. Virtual page number Page Table Physical memory Disk storage Figur 6: Page Table. Det virtuelle sidenummeret brukes til å slå opp i tabellen, som viser hvor siden befinner seg. Ved page hit finnes siden i hovedminnet, og det fysiske sidenummeret returneres. Ved page fault befinner siden seg et sted på disk. En egen tabell returnerer en diskadresse som angir plassering. Operativsystemet starter opp en rutine som kan hente inn den aktuelle siden og plassere den i hovedminnet. En LRU-mekanisme avgjør hvilken fysisk side som blir kastet ut. Denne må evt. skrives til harddisk først. Til slutt leses den ønskede siden fra harddisk og plasseres i hovedminnet, samtidig som tabellen med oversatte sidenummer blir oppdatert. Figuren er hentet fra [14]. En page fault medfører at siden må leses inn fra disk, noe som tar lang tid. Et program som behandler større datamengder enn det som får plass i hovedminnet, risikerer disk trashing. Da flyttes sider mellom disk og hovedminnet langt oftere enn det som behøver å være nødvendig. 16

2.7.4 Translation Lookaside Buffer Sidetabellen befinner seg i hovedminnet, og direkte avlesning tar derfor noe tid. Men lokalitetsprinsippet gjelder her også. Når det først skal refereres til data fra en bestemt side, er det høy sannsynlighet for at det er behov for å referere flere data i den samme siden i løpet av kort tid. Prosessoren har derfor et eget cache kun for de sist brukte sideoversettelsene. Denne cache-enheten kalles Translation Lookaside Buffer, (TLB). Hvis TLB inneholder en oversettelse av det etterspurte sidenummeret, har vi både en TLB hit og en page hit. Ved en TLB miss må det slås opp i den fullstendige sidetabellen, slik at dette vil enten medføre page hit eller page fault. Det tar noen titalls klokkesykler å oppdatere TLB ved page hit. Slike rene TLB miss vil oppstå langt hyppigere enn page fault. Som regel har prosessorer ganske små TLB cache. F.eks. kan TLB i R14000 romme 64 par av like/odde sidenummer, tilsammen 128 oversettelser. Tilsammen rekker da TLB over områder i minnet på totalt 128 16kB, eller 2MB. Vi ser at dette ikke er stort nok for L2 cache. TLB bør rekke over størrelsen på det working set som programmet benytter seg av, ellers vil det foretas mange ekstra oppdateringer av TLB. Siden det oppstår en forsinkelse ved TLB miss, vil dette hindre optimal ytelse. Hvis det utføres arbeid på data som er spredt ut over for mange virtuelle sider, kan det i verste fall oppstå TLB thrashing. Det kan være nødvendig med en grundig forståelse av organisering og aksess av datastrukturene, for å gjøre de nødvendige endringer i koden. En annen mulighet er å endre sidestørrelsen til f.eks. 64kB. 2.7.5 Integrering av virtuelt minne og cache L1 data cache er virtually indexed, physically tagged. Ved minneaksess bruker cache de nederste bit-verdiene av den virtuelle adressen som indeks til hvilket sett data evt. befinner seg i. Samtidig slås det opp i TLB for å finne den fysiske adressen. I det neste steget sammenlignes de to tag-verdiene i det aktuelle settet med de øverste bit-verdiene fra den fysiske adressen. En likhet her innebærer cache hit. Ved oppslag i L2 cache brukes derimot bare den fysiske adressen. Det er verdt å presisere at oppslag i cache, TLB og page table delvis er uavhengige av hverandre. I beste fall fås både TLB hit og cache hit, men 17

det er også mulig med TLB miss etterfulgt av page hit og cache miss. Andre situasjoner vil også oppstå. God forståelse av minnehierarkiet, slik det er implementert på en datamaskin, kan være nødvendig for å finne ut hvor i minnehierarkiet et program har problemer. Profileringsverktøy kan være til hjelp i dette arbeidet, f.eks. ved å vise mønstre for minneaksess. Vi kommer tilbake til dette i seksjon 5. 2.8 Oppsummering I en moderne datamaskin er prosessorkjerne, minnehierarki og virtuelt minne knyttet sammen på en kompleks måte. Programmereren benytter seg av en forenklet programmeringsmodell. Modellen skjuler f.eks. akkurat hvilke funksjonelle enheter som finnes, og minnehierarkiet er erstattet med et flatt minne. En av oppgavene for kompilatoren er å lage et program som utnytter ressursene best mulig. God ytelse krever at det er høy gjennomstrømning i de funksjonelle enhetene i prosessorkjernen. En prosessor som R14000 kan endre på instruksjonsrekkefølgen for å bedre denne gjennomstrømningen. Teoretisk oppnåelig ytelse bestemmes av klokkehastighet og de superskalare egenskapene. Den ytelsen som det er realistisk å oppnå i praksis, kan være betraktelig lavere. God bruk av minnehierarkiet er svært viktig. Høy ytelse avhenger av at de aller fleste minnereferansene oppfylles i cache. Tabell 1 oppsummerer latenstiden i minnehierarkiet til R14000 og SGI Origin 3000. 7 Tabell 1: Latenstid i minnehierarkiet, SGI Origin Aksesstype Klokkesykler Register 0 L1 cache 2 3 L2 cache 10 12 Hovedminne 100 200 Harddisk 10 6 Det virtuelle minnet sikrer at programmet har tilgjengelig tilnærmet ubegrenset minne, og fungerer som en abstraksjon av det fysiske minnehierarkiet. Ideelt sett oppleves det virtuelle minnet som transparent, i betydning at 7 Disse er veiledende, da vi ikke fant fullstendig informasjon i kildene. 18

programmereren ikke trenger å ta hensyn til det. Men ytelsen kan tape på for mange TLB miss, eller bli fullstendig ødelagt av hyppige page fault. I de senere år har det skjedd en stor utvikling i prosessor-arkitektur, og noen trekk ved nyere prosessorer beskrives i seksjon 5. Tabell 2 i 5.1.5 gir en oppsummering av deres egenskaper og funksjonalitet. Moderne prosessorer er ofte langt mer kompliserte enn R14000, men har likevel mye av den samme organiseringen som er beskrevet her. 19

3 Optimalisering av daxpy Som vi har sett, er det mange faktorer som kan påvirke ytelsen til programkoden. Hvor stor betydning de enkelte har, vil avhenge av de enkelte program eller beregninger som utføres. For å lære noe om hva de kan ha å si for numeriske beregninger, studerte vi derfor noen basisoperasjoner i lineær algebra. Vi så nærmere på arbeidet de utfører og hvordan de oppførte seg i praksis. Her ser vi på et eksempel som er hentet fra [13], vektoroperasjonen daxpy. Vi ser på matrisemultipliksjon i seksjon 4. For å motvirke dårlig ytelse kan programmereren gjøre noen programmatiske grep og øve innflytelse på koden som produseres av kompilatoren. Vi introduserer noen av disse temaene underveis. 3.1 Generelle optimaliseringsnivå Kompilatorsystemet MIPSpro på SGI Origin er beskrevet i [18, 13, 12, 11, 9], og vi har gitt en enkel oversikt i appendiks A. Her går vi bare inn på noen av de viktigste sidene ved det. Det finnes flere generelle optimaliseringsnivåer, som følger: -O0 Ingen optimaliseringer. -O1 Optimaliseringer kun lokalt i koden. -O2 Ekstensive, men konservative optimaliseringer, f.eks. utrulling av løkker. Disse kan nesten alltid anbefales. -O3 Aggressiv optimalisering, bl.a. software pipelining og omskrivninger av kode. Flyttallspresisjon kan påvirkes. Løkketransformasjoner med tanke på bedre utnyttelse av cache. -Ofast=ip35 Globale optimaliseringer, dvs. at alle programmodulene (objektfilene) ses under ett. Optimaliseringer rettet mot spesifikk maskintype, angitt ved maskinnummer. (Her angitt for SGI Origin 3000-serien). Når disse nivåene benyttes, vil det påvirke valg for flere av modulene vist i Figur 13 i A.2 samtidig. Ekstra opsjoner rettet mot enkeltområder kan brukes i tillegg. For de høyeste nivåene bør en merke seg at de i noen situasjoner kan gi dårligere ytelse. Kompilatoren har stort spillerom og foretar mange 20

heuristiske valg, basert på mange typiske tilfeller, når den transformerer og kompilerer koden. 3.2 Problembeskrivelse I daxpy multipliseres en vektor med en skalar, og resultatet legges til en annen vektor, som samtidig overskrives. I Fortran-notasjon kan dette se slik ut: real*8 a, x(n), y(n) do i = 1, n y(i) = y(i) + a*x(i) enddo For hver gjennomløpning av løkka må det utføres flere instruksjoner, de viktigste for denne diskusjonen er: To load-operasjoner (av elementene x(i) og y(i)) En store-operasjon (av y(i)) En multiply-add operasjon I tillegg må det utføres pekeroppdateringer av indeksene for de to arrayene, og det må utføres en test av i for å sjekke betingelsen for avslutning av løkka. Dette arbeidet utføres av heltallsenhetene, og vi skal se bort fra dette her. Vi forklarte i 2.4 at prosessoren, når den utfører multiply-add, har en teoretisk toppytelse (i MFlops) på det dobbelte av klokkehastigheten. Her ser vi at det for hver multiply-add også er behov for tre minneoperasjoner, noe prosessoren bare kan utføre én av hver klokkesykel. Derfor vil den maksimale ytelsen som det er mulig å oppnå for daxpy, være en tredel av toppytelsen for prosessoren. Dette tilsvarer 333 og 400 MFlops for prosessorene på hhv. Gridur og Embla. 3.3 Program og eksperiment Vi skrev et program som allokerte og initialiserte vektorer av ulik lengde. Selve daxpy-operasjonen implementerte vi i både Fortran og C. Disse ble kompilert ved ulike optimaliseringsnivåer, samt med bruk av noen andre opsjoner i tillegg. Eksperimentene ble kjørt på en enkeltprosessor på Gridur. 21

Vi brukte vektorer med flyttall i dobbel presisjon, slik at hvert element opptok 8 bytes i minnet. Vi brukte vektorlengder som kan skrives som 2 k, k heltall, eller summen av to slike. I 2.6.3 nevnte vi at slike arraydimensjoner kan være problematiske. 3.4 Grunnleggende temaer Vi begynte med å teste ytelsen slik den var ved å bruke -O2. Dette nivået burde være en god referanse, i og med at kompilatoren ikke foretar store endringer av koden, som nevnt i 3.1. Vi testet også ytelsen ved -O1, selv om normal kode neppe bør bruke dette nivået i det hele tatt. Figur 7 viser den oppnådde ytelsen. Se også B.1 for kommentarer om hvordan målingene ble utført. Vi fortsetter med noen generelle tema, forklart for dette tilfellet, som kompilert ved nivået -O2. Denne diskusjonen fortsetter med flere teknikker i 3.5. 3.4.1 Utrulling av løkker Ved å rulle ut en løkke kan flere beregninger gjøres per iterasjon, samtidig som antall iterasjoner reduseres. På denne måten kan de superskalare egenskapene til prosessoren benyttes bedre. F.eks. vil en to ganger utrullet versjon av beregningsløkka i daxpy se slik ut (den andre løkka utføres bare dersom n er odde): do i = 1, n-1, 2 y(i) = y(i) + a*x(i) y(i+1) = y(i+1) + a*x(i+1) enddo do j = i, n y(j) = y(j) + a*x(j) enddo Når løkka er rullet ut på denne måten, begynner hver iterasjon av løkka med fire load-instruksjoner rett etter hverandre. Siden de funksjonelle enhetene bruker pipelining, kan hver load startes opp med bare én klokkesykels mellomrom. Operandene hentes inn i rekkefølgen x(i), x(i+1), y(i), y(i+1). Så snart x(i) er hentet inn fra L1 cache kan den første multiplikasjonen startes opp. Den neste multiplikasjonen startes opp i neste klokkesykel, når 22

350 300 Fortran, O1 C, O2 Fortran, O2 250 200 MFlops 150 100 50 0 10 0 10 1 10 2 10 3 10 4 10 5 10 6 10 7 10 8 Vektorlengde N Figur 7: Ytelse for daxpy, -O1 og -O2. Den oppnådde ytelsen er vist som funksjon av vektorlengden. -O2: For både Fortran og C stiger ytelsen jevnt og passerer 300 MFlops allerede ved n = 32, før den flater ut mot den teoretiske toppytelsen på 333 MFlops. Med vektorlengden n = 2048 består de to vektorene av tilsammen 4096 elementer. Dette gir en minnebruk på 32kB, som akkurat får plass i L1 cache. Deretter faller ytelsen kraftig. For vektorlengder som får plass i L2 cache har Fortran-rutinen en ytelse rundt 160 MFlops. C-rutinen oppnår bare halvparten av dette. Ytelsen faller igjen markert når vektorene ikke lenger får plass i L2 cache. Dette skjer omlag ved n = 524288, siden vektorene da bruker 8MB minne. Systembussen blir en flaskehals etterhvert som elementene i vektorene må hentes fra hovedminnet. Ytelsen ligger da i overkant av 10 MFlops. -O1: Ytelsen er jevnt dårlig, selv for data i L1 cache, og endres ikke så mye etterhvert som L2 cache tas i bruk. For de største datamengdene er likevel dette nivået bedre enn -O2. 23

x(i+1) er klar. På samme måte følger de to addisjonene. De to operandene y(i) og y(i+1) er klare akkurat når multiplikasjonene er gjennomført, og prosessoren sender resultatene fra multipliasjonsenheten direkte til addisjonsenheten, uten å skrive til register først. Det tar noen klokkesykler før de endelige resultatene er skrevet til register. Iterasjonen avsluttes med to store-instruksjoner som også følger rett etter hverandre, og en sjekk av iterasjonsvariablen. I tillegg benyttes heltallsenhetene til pekeroppdatering underveis. Som vi har sett, kan ulike instruksjoner overlappe hverandre. I dette tilfellet kan en slik iterasjon foretas på 8 klokkesykler og gjennomføre 4 flyttallsoperasjoner, av 16 mulige overhodet. Derfor når dette instruksjonsskjemaet 8 opp til en fjerdedel av prosessorens toppytelse, som altså ikke er så langt unna det som er mulig for daxpy. Ved optimaliseringsnivå -O2 og høyere vil kompilatoren som nevnt i 3.1 automatisk utføre utrulling av løkker. Høyere grad av utrulling gjør det generelt sett mulig å oppnå enda bedre ytelse enn i eksemplet over. Kompilatoren vil velge grad av utrulling ut fra hva slags kode som kompileres og hva slags enheter som brukes. Men med høyere grad av utrulling blir det også behov for å bruke flere registre, så dette kan være en begrensning. I 4.2.3 forklarer vi mer om dette temaet. 3.4.2 Kontroll av generert kode På bakgrunn av erfart ytelse til vårt program, er det interessant å se hvilke valg kompilatoren faktisk gjorde. Ved å bruke opsjonen -S, vil kompilatoren generere assembly-kode med kommentarer, istedenfor vanlig objektkode. Denne filen inneholder også de opprinnelige programlinjene fra kildefilen, i kommentars form. Ved å la kildefilen inneholde kun en rutine, blir det lettere å lese gjennom assemblykoden. For Fortran-versjonen av daxpy-rutinen ga kompilatoren følgende informasjon om den genererte koden: #<loop> Loop body line 4, nesting depth: 1, estimated iterations: 50 #<loop> unrolled 2 times #<sched> #<sched> Loop schedule length: 8 cycles (ignoring nested loops) 8 Dette er bare ett mulig eksempel på et slikt skjema for denne graden av utrulling. 24

#<sched> #<sched> 4 flops ( 25% of peak) (madds count as 2) #<sched> 2 flops ( 12% of peak) (madds count as 1) #<sched> 2 madds ( 25% of peak) #<sched> 6 mem refs ( 75% of peak) #<sched> 3 integer ops ( 18% of peak) #<sched> 11 instructions ( 34% of peak) Prosentandelene kan si oss noe om i hvor stor grad de superskalare egenskapene blir benyttet av dette skjemaet. Siden R14000 er 4-veis superskalar, kan det maksimalt utføres 32 instruksjoner i løpet av 8 klokkesykler. Når det utføres 11 totalt, er dette 34% av det som er oppnåelig. 3.4.3 Parallellisme på instruksjonsnivå Som vi forklarte i 2.3.2 har prosessoren et vindu av instruksjoner som er dekodet og befinner seg i køstrukturene. Instruksjonene blir startet opp i den rekkefølgen prosessoren finner hurtigst. De superskalare egenskapene i en prosessor er ment å gjenspeile instructionlevel parallelism (ILP) i programmer. Sekvensielle instruksjoner kan være uavhengige av hverandre, slik at de kan utføres samtidig. Utrulling av løkker hjelper til å eksponere slik parallellisme. I vårt eksempel var den målte ytelsen tilnærmet det oppnåelige for daxpy, sålenge data var i L1 cache. Kommentarene til skjemaet indikerte derimot at koden ikke var optimal, i og med at ressursene ikke ble benyttet fullt ut. Posessoren starter altså opp instruksjonene i et enda bedre skjema når den utfører programmet. 3.4.4 Array Padding Vektorlengdene som ble brukt ga dårlig ytelse for C-varianten av programmet, når data var i L2 cache. En vanlig teknikk for å endre hvordan blokkene blir plassert i cache, er å allokere ekstra tomme elementer i arrays. I dette tilfellet vil det si å allokere n + 1 elementer i minnet, når operasjonen skal utføres på vektorer med lengde n. Dette kalles array padding. Da vi brukte slik padding på vektorene som ble brukt av C-rutinen, ble ytelsen nærmest sammenfallende med Fortran-rutinen. Vi brukte profilerings- 25

verktøy, men kunne ikke påvise noen forskjell i antall cache-miss mellom de to variantene. Istedenfor å gå nærmere inn på hva som foregikk, går vi heller inn på en viktig teknikk for å forbedre koden for C-rutiner i 3.5.4. 3.5 Videregående temaer Kompilatoren har mulighet til å gjøre store endringer i koden som den finner nødvendig for å forbedre ytelsen. De generelle optimaliseringsnivåene er med på å bestemme hvor stor frihet kompilatoren har, og hvilke antagelser den kan gjøre. Ved bruk av de to høyeste optimaliseringsnivåene, -O3 og -Ofast, vil kompilatoren, i tillegg til optimaliseringene foretatt ved de lavere nivåene, bruke flere moduler og foreta aggressive optimaliseringer. Se også Figur 13 i A.2. Modulen Loop Nest Optimizer (LNO) brukes til forbedre bruken av minnehierarkiet. Bruken av LNO skjer i en av de tidligste fasene etter at kildekoden har blitt oversatt til den mellomliggende representasjonen. Vi gir en utvidet forklaring i 4.2 der dette temaet hører mer naturlig hjemme. Her tar vi med teknikken prefetching av data, som LNO kan benytte seg av for en rutine som daxpy. Først ser vi hvordan kodegeneratoren bruker software pipelining for å bedre ytelsen. Dette er en teknikk som brukes for å ta enda bedre hensyn til ILP. 3.5.1 Software Pipelining Vi har allerede sett at kompilatoren kan sette opp et godt instruksjonsskjema for daxpy etter å ha foretatt løkkeutrulling. På de høyeste optimaliseringsnivåene har kodegeneratoren større frihet til å transformere koden. Denne teknikken er nærmere forklart i [13, 9]. Vi forklarer software pipelining av daxpy i Figur 8. Vi kompilerte Fortran-versjonen av rutinen på nivået -O3, men unnlot å bruke LNO. Kodegeneratoren ga følgende kommentar om de to replikasjonene som foretar det aller meste av beregningene: #<swps> Pipelined loop line 4 steady state #<swps> #<swps> 50 estimated iterations before pipelining #<swps> 2 unrollings before pipelining 26

Windup Replication 0 Replication 1 Winddown Figur 8: Software pipelining av daxpy. Kodegeneratoren kan f.eks. transformere den opprinnelige løkka med utgangspunkt i å rulle ut to eller flere ganger. Deretter lager den fire kodesegmenter, hhv. kalt Windup, Replication 0, Replication 1 og Winddown. Den første delen brukes bare én gang, og laster inn de første operandene og starter opp de første multiply-add operasjonene. De to neste delene er tilnærmet like, og det er disse det itereres over (vist med den nederste pilen) for å utføre de aller fleste beregningene. Når de siste beregningene er påbegynt, hoppes det ut av replikasjonene. Dette kan skje etter den første (vist med den øverste pilen) eller andre replikasjonen. Til slutt brukes den siste delen én gang for lagring av de siste resultatene. På denne måten vil hver replikasjon først lagre resultater fra en tidligere iterasjon, før den starter opp nye beregninger. Disse avsluttes i neste replikasjon, eller i den aller siste delen. Ved å spre instruksjoner fra forskjellige iterasjoner på denne måten, kan skjemaet som settes opp bli enda bedre enn ellers. I tillegg til disse delene, lages det også en test for hvor mange ganger løkka skal gjennomløpes og kode som foretar beregningene hvis det bare skal itereres et lite antall ganger. Assembly-koden blir dermed også vanskelig å lese. 27

#<swps> 6 cycles per 2 iterations #<swps> 4 flops ( 33% of peak) (madds count as 2) #<swps> 2 flops ( 16% of peak) (madds count as 1) #<swps> 2 madds ( 33% of peak) #<swps> 6 mem refs (100% of peak) #<swps> 3 integer ops ( 25% of peak) #<swps> 11 instructions ( 45% of peak) #<swps> 2 short trip threshold #<swps> 4 integer registers used. #<swps> 6 float registers used. Noen uheldige programmatiske konstruksjoner kan umuliggjøre bruk av software pipelining. Vi går ikke inn på disse her, bortsett fra å nevne et par av dem i de neste avsnittene. Men det er enkelt å kontrollere om kodegeneratoren måtte gi opp å bruke denne teknikken. Kommentarene vil da begynne med #swpf istedet. Forklaringene på hva slags problemer som oppstod kan være vanskelige å forstå. 3.5.2 Prefetching LNO estimerer hvilke minneaksesser som vil føre til cache miss, og om dette vil skje i L1 eller L2 cache. Kodegeneratoren vil etterpå forsøke å laste disse operandene inn fra minnet en liten stund før det egentlig er bruk for dem. God bruk av prefetching kan endre flaskehalsen mellom prosessor og hovedminnet fra latenstiden for minneaksess til båndbredden som er tilgjengelig. Prefetching kan foretas på to måter. Hvis en minneaksess antas å føre til L2 cache miss, vil kodegeneratoren sette inn en instruksjon som en stund i forveien sjekker om data befinner seg i cache, og som evt. henter dem inn i cache hvis de ikke befinner seg der. Hvis data stort sett faktisk befinner seg i cache, medfører denne metoden at mange unødvendige instruksjoner må behandles av prosessoren. Dette kan hindre optimal ytelse. Den andre måten brukes for minneaksess som antas å føre til L1 cache miss. Kodegeneratoren vil da sørge for at load-operasjoner startes opp så tidlig at operandene er tilgjengelig i registrene når det er behov for dem. Denne metoden bruker ikke noen ekstra instruksjoner. Derimot kan instruksjonsskjemaet bli lengre enn det ellers ville vært, for å ta hensyn til at minneaksess fra L2 cache tar flere klokkesykler enn tilsvarende fra L1 cache. Dermed kan ytelsen påvirkes negativt, noe vi kan se på assembly-kommentarene når vi kompilerte 28