Obligatorisk oppgave 1 INF-3201 < Parallellprogrammering> 13. oktober 2003 Tor-Eirik Bakke Lunde torebl@stud.cs.uit.no
0: Analyse av sekvensiell kode: Identifiser og beskriv datastrukturer: Den sekvensielle nbody-koden benytter seg av to datastrukturer for å lagre data. Hvorav strukturen coords definerer to double verdier for å angi enheter fordelt i henholdsvis x og y retning, og strukturen body_s utgjør lagringstypen som blir bruk for å danne den enkeltlinkede listen som benyttes. I seg selv er disse fullt brukbare i sekvensiell kode, men for å parallellisere koden må bruken av disse elimineres eventuelt overføres til noe mer brukbart (fra mpi bibliotekets standpunkt). Dette rett og slett fordi lenkede lister per definisjon har hvert element spredd utover det fysiske minnet, og det ville vært betraktelig enklere dersom det lå sekvensielt og en kunne håndtere en enkelt blokk data, istedenfor x antall små. For å oppsummere hovedsaklige datastrukturer: coords datastruktur body_s datastruktur, sammensatt av coords og 2stk pointere. Enkeltlenket list, bestående av body_s for organisering av data. Størrelse varierende etter inndata. Identifiser dataavhengigheter: Ved å studere metoden calculate_forces er det lett å se at algoritmen ikke har noen hukommelse, dvs at hvert resultatene fra hvert tidssteg ikke er direkte avhengige av hverandre utover å danne grunnlaget for neste kalkulasjoner (F.eks Leapfrog algoritmen der enkelte verdier kalkuleres vekselvis). Utdrag: if (i == j) continue;... b[i]->f.x = b[i]->f.x + magnitude * direction.x / distance; b[j]->f.x = b[j]->f.x - magnitude * direction.x / distance; b[i]->f.y = b[i]->f.y + magnitude * direction.y / distance; b[j]->f.y = b[j]->f.y - magnitude * direction.y / distance; Ved å se på utdraget fra calculate_forces ovenfra kan en se at algoritmen for hver gjennomgang av påvirker verdiene til ikke bare element i, men også element j. Dette er en teknikk brukt for å øke ytelsen til denne sekvensielle algoritmen ved å utnytte at element i påvirker en kraft f ij på element j, og dermed også at element j påvirker i med en kraft f ji. Forholdet mellom disse to kreftene er ganske enkelt: f ij = - f ji Dette kunne blitt utnyttet bedre og spart ytterligere kalkulasjoner. If setningen inkludert øverst er tatt med ettersom at det ble antydet at denne var feil, men ved studie av kode kan man se at dersom en beregner avstanden mellom element i og j, når i==j vil en finne at de da er samme element og avstanden mellom dem blir følgelig null. De neste linjene i utdraget ville da endt opp som division by zero og resultatet lagret ville følgelig være nan (Not a number). Oppsummering: Resultater fra en gjennomgang av kode danner datagrunnlaget for neste. Utregninger utført påvirker både element j og i.
Finn Big-O for algoritmene: I den stand den sekvensielle algoritmen ble utlevert kan man observere følgende: for (i = 0; i < N; i++) { for (j = 0; j < N; j++) {... } } Utfra dette er det lett å konkludere med at algoritmen i O-notasjon tilsvarer O(n 2 + n), der + n vil være marginal ved stor N derfor står en igjen med den dominerende delen - O(n 2 ).
1: Parallellisering (Decomposition): Global synkronisering ved hver tidsperiode Data kun avhengig av data fra forrige tidsperiode (med litt modifikasjon som forklart i analysen av dataavhengigheter) Elementer kun avhengig av data fra forrige tidsperiode. Identifisering av parallelliserbare punkter: Hovedarbeidet som blir gjort i den sekvensielle koden utover rent initialisering og innlesing av data fra hard-disk blir foretatt i funksjonene calculate_forces og move_bodies, og det er her nøkkelen til en potensielt økt ytelse ligger. Funksjonene calculate_forces : O(n 2 ), dvs svært kostbar løkke og skal parallelliseres. Funksjonen move_bodies : O(n), enkel løkke som oppdaterer elementenes posisjon etter hver kalkulasjon. Selve parallelliseringen: Valgte en svært enkel løsning, med å benytte seg av en mindre effektiv, men fortsatt mye bedre parallellisert O(n 2 ) algoritme. Datastrukturer: Den sekvensielle koden benytter seg av en linket liste som er vanskelig og en stor feilkilde under utvikling av parallelle programmer, samt at vi trenger en datatype MPI forstår. Derfor definerer vi en ny datastruktur som er mer MPI-vennlig, og vises nedenfor. typedef struct { double px, py; double vx, vy; double fx, fy; double m; } Body; MPI_Datatype MPI_BODY; Ved å benytte oss av en slik struktur, bestående av homogene datatyper kan en definere en ny MPIdatatype basert på denne. Etter å ha gjort dette kan en med enkelhet utveksle data av denne typen uten frykt for feil. Fremgangsmåten for dette vises nedenfor, og er avhengig av dataen definert i forrige kodeutsnitt. MPI_Type_contiguous(7, MPI_DOUBLE, &MPI_BODY); MPI_Type_commit(&MPI_BODY); Ettersom vi allerede har funksjonalitet som leser data inn fra filer ville det vært bortkastet å prøve å spare noen få prosessorsykluser på å skrive om disse. Isteden defineres en ny pointer øverst i koden, og etter at data er lest inn fra disk allokeres en passende stor datablokk for å holde denne dataen og kopierer dem inn i det mer brukbare formatet, og måten dette blir gjort på kan observeres i siste delen av read_input funksjonene. Algoritmen: Som nevnt er dataen som brukes (lagret i minneområdet referert til av pekeren bodies ) avhengig kun av forrige tidsperiode. Dermed kan en sikkert dele opp elementene i grupper, der hver gruppe elementer
blir tildelt en prosess som skal foreta utregningene på dem. Ettersom denne implementasjonen er av en enkel type trenger en bare gjøre dette en gang, og blir da gjort under initialiseringen i starten av programmet. Den parallelle algoritmen fungerer nå slik: 1. Root prosessen broadcaster all informasjon ut til hver prosess. 2. Hver prosess foretar utregninger på sin utdelte gruppe elementer, inkludert regne ut krefter og endre posisjoner. 3. Root samler inn all data, slik at den har oppdatert informasjon om hvert element. 4. Gjenta steg 1-3 for hver tidsperiode Tildeling av arbeidsområde (Assignment): Statisk tildeling av grupper av elementer, p prosesser, rang 0 (p-1). Grupper blir dannet opprettet slik etter at root har lest inn data fra disk, og sendt ut antallet elementer (N) til resten av prosessene: mystart = (int *) malloc(size * sizeof(int)); myend = (int *) malloc(size * sizeof(int)); mystart[0] = 0; myend[0] = N / size; for (i = 1; i < size; i++) { mystart[i] = myend[i - 1]; myend[i] = mystart[i] + N / size; } myend[size - 1] = N; Prosess i blir tildelt element mystart[i] til myend[i]. God arbeidsfordeling ettersom hver prosess arbeider like mye (antallet elementer kan gi en prosess noen elementer mindre, men ingen som får en størstepart.). Dersom programmet kjøres i ett homogent miljø vil dette fungere utmerket, viss ikke vil den raskeste prosessen bli stående og vente på de tregere prosessene. Utveksling av data: Root foretar MPI_Bcast( ), og dermed kopierer all data om alle elementene ut til hver prosess. Etter å ha gjennomført sine kalkulasjoner sender hver prosess sitt resultat til root prosessen, som da setter det hele sammen til ett helhetlig resultat. Dette gjøres ved at MPI_Recv( ) kjøres i en løkke på root prosessen og tar imot data fra de andre prosessene.
2. Evaluering: Ettersom mye utveksling av data foregår innblandet i utregningene, så istedenfor da å tynge ned algoritmen med tidsutregninger har jeg valgt å ta en enkel løsning der initialisering plasseres utenfor målingen og slår sammen tid brukt på utregning og utveksling av data. Dette vil si at for å få ett bedre bilde av hva som foregår tas tiden ifra algoritmens run( ) starter og til den er ferdig. Selve tidsmekanismen som benyttes er MPI sin egen MPI_Wtime() funksjon. I den sekvensielle koden er ikke denne funksjonen tilgjengelig, så der er tiden differansen mellom to timeval strukturer som er fylt med data av ett kall til gettimeofday() funksjonen. Selve målingene foretas på økende antall bodies fordelt på forskjellige antall prosesser brukt til selve utregningene. Henholdsvis 100, 250, 500, 750 og 1000 bodies på 1 (sekvensiell kode), 2, 4 og 6 prosesser. Alle målingene er foretatt på Snowstorm clusteret, og nodene Compute-1-[0-5]. De kalkulerte tidene, vist på y aksen, viser hvor lang tid kalkulasjonene tok ved forskjellige mengder elementer, vist på y aksen. Den sekvensielle koden gjorde det rimelig akseptabelt med ett lavere antall elementer, men ved høyere antall elementer går med høy fart imot en praktisk uendelighet når det kommer til prosesseringstid. Ettersom den parallelle algoritmen er avhengig å overføre mye data blir fortjenesten med å øke antallet prosesser stadig mindre, men sammenlignet med den sekvensielle gjør selv bruken av to prosesser en betraktelig forskjell. En kan tydelig se ut fra grafen at ved å øke antallet prosesser fra 2 til 6 blir ikke prosesseringstiden redusert med 2/3, men kun litt over halvert dette illustrerer hvordan økt dataoverføring til slutt kommer til å overta som den begrensede ressursen, og ikke prosessering som var tilfellet tilfelle. Tendensen tyder på at ved bruk av mer enn ca 8-12 prosesser, vil fortjeneste bli negativ som følge av dette.