Optimale parallellisatie bij het uitschrijven en uitvoeren van programmacode Samnang Nop Promotor: dr. ir. Koen Casier Begeleider: Jonathan Spruytte Masterproef ingediend tot het behalen van de academische graad van Master of Science in de industriële wetenschappen: elektronica-ICT Vakgroep Informatietechnologie Voorzitter: prof. dr. ir. Daniël De Zutter Faculteit Ingenieurswetenschappen en Architectuur Academiejaar 2014-2015 ! Optimale parallellisatie bij het uitschrijven en uitvoeren van programmacode Samnang Nop Promotor: dr. ir. Koen Casier Begeleider: Jonathan Spruytte Masterproef ingediend tot het behalen van de academische graad van Master of Science in de industriële wetenschappen: elektronica-ICT Vakgroep Informatietechnologie Voorzitter: prof. dr. ir. Daniël De Zutter Faculteit Ingenieurswetenschappen en Architectuur Academiejaar 2014-2015 ! Voorwoord Deze thesis maakt deel uit van de opleiding Master of Science in de industriële wetenschappen: elektronica-ICT aan de Universiteit Gent. Het was niet gemakkelijk om vorig jaar een keuze te maken voor het onderwerp van mijn masterproef. In het vakgebied van de ingenieurswetenschappen zijn de keuzes namelijk enorm uiteenlopend. “Kiezen is verliezen”, zegt men … niet? Toen ik in gesprek kwam met een docent omtrent de keuze van mijn masterproef vertelde hij me iets wat mij altijd zal bijblijven: “Als wij het kunnen denken en als wij het kunnen voorstellen, dan moet er een manier zijn om dit in computertalen te kunnen vertalen”. De docent had het over parallel programmeren. Een uitdagend onderwerp dat momenteel nog in kinderschoenen staat bij de doorsnee programmeur. Maar, als je er even over nadenkt, kan hetgeen wat de docent mij verteld had niet in een veel bredere context worden aangenomen? Is dit niet de brandstof van de huidige technologie? De drijfkracht dat ervoor heeft gezorgd dat er momenteel miljoenen transistors op enkele vierkante cm2 kan worden gebracht? De drijfkracht dat ervoor heeft gezorgd dat we in slechts een fractie van een seconde de andere kant van de wereld kunnen bereiken? Dit heeft me getriggerd, en het is daarom niet toevallig dat de docent in kwestie, Koen Casier, mijn promotor is van de thesis met titel: Optimale parallellisatie bij het uitschrijven en uitvoeren van programmacode. Ik wil als eerste mijn promotor, Koen Casier, en mijn begeleider, Jonathan Spruytte, bedanken voor de ettelijke uren en die ze hebben vrijgemaakt om mij te begeleiden tijdens de uitwerking van mijn thesis, voor de koffie en de steun die ze mij aanboden op de momenten waarop de uitdaging te groot leek en voor de luchtige gesprekken die totaal niets te maken hadden met de masterproef. Als laatste wil ik de volgende personen bedanken die mij vormden tot wie ik nu ben: Kristof Hennebel, Dries Seuntjens en Pieter Decock (de ingenieurs van de toekomst). Timon Mattelaer en Tijs Foulon (de ontwerpers van de toekomst). David en Mathieu Santy (de muzikanten van de toekomst). Lore Declercq (de vrouw van mijn toekomst). I Toelating tot bruikleen De auteur geeft de toelating deze masterproef voor consultatie beschikbaar te stellen en delen van de masterproef te kopiëren voor persoonlijk gebruik. Elk ander gebruik valt onder de bepalingen van het auteursrecht, in het bijzonder met betrekking tot de verplichting de bron uitdrukkelijk te vermelden bij het aanhalen van resultaten uit deze masterproef. The author gives permission to make this master dissertation available for consultation and to copy parts of this master dissertation for personal use. In the case of any other use, the copyright terms have to be respected, in particular with regard to the obligation to state expressly the source when quoting results from this master dissertation. Samnang Nop, 27 mei 2015 II Overzicht Optimale parallellisatie bij het uitschrijven en uitvoeren van programmacode door Samnang Nop Promotor: dr. ir. Koen Casier Begeleider: Jonathan Spruytte Masterproef ingediend tot het behalen van de academische graad van Master of Science in de industriële wetenschappen: elektronica-ICT Vakgroep Informatietechnologie Voorzitter: prof. dr. ir. Daniël De Zutter Faculteit Ingenieurswetenschappen en Architectuur Academiejaar 2014-2015 Deze paper kadert in de onderzoekslijn elektronica-ICT van de Universiteit Gent Campus Kortrijk. Het doel is om een ontwerp te realiseren dat optimale parallellisatie brengt bij het uitschrijven en uitvoeren van programmacode in Java. De focus wordt vooral gelegd op het behouden van performance. Echter, het programmeren van multi-core applicaties is vaak niet gemakkelijk. Als eerste moet het geheugen in rekening gebracht worden, in die zin dat data ten aller tijd consistent moet zijn. Ten tweede moet de programmeur leren concurrency te exploiteren (dit zijn stukjes code, die onafhankelijk uitgevoerd kunnen worden, waardoor het incapsuleren in threads parallelle uitvoering toelaat). Als laatste is er nog de specifieke, parallelle syntax die in rekening gebracht moet worden waardoor code minder leesbaar begint te worden. Deze thesis richt zich op een standaard high-end object georiënteerde taal, Java. Het doel is om uiteindelijk een manier te vinden waarmee de programmeur met groot gemak een reeds bestaande code kan omvormen naar veilige, parallelle, object oriented code. Baserend op het OpenMP model (dat momenteel enkel beschikbaar is voor C/C#/C++/Fortan) werd een gelijkaardig model bekomen voor Java: Methodes die parallel uitgevoerd kunnen worden, worden aangeduid met een markering (Java annotation). Met behulp van de Aspect georiënteerde programmeertechniek (een tool dat toelaat om at runtime code te injecteren) worden die methodes opgevangen, geanalyseerd en daarna vervangen door een parallel patroon. Een parallel patroon zal taken of data veilig splitsen en die dan III incapsuleren in een thread. Deze threads kunnen dan uitgevoerd worden op de afzonderlijke cores. Eventuele parameters van de oorspronkelijke methode worden opgevangen via Java Reflections. @ParallelKeyword public * method(args...) {...} AspectJ compiler geännotteerde code Concurrente en Replace code parallelle code bibliotheek Java compiler Parallelle uitvoering van de methode Dit model werd getest door het uitbouwen van enkele prototypes waarvan de resultaten plausibel zijn: Ten eerste werd er snelheidswinst bekomen door het splitsen van forloops (absurdely-for) en die te laten uitvoeren op verschillende cores. Ten tweede werd een prototype uitgebouwd waarbij methodes simultaan werden uitgevoerd in een soort race, met als doel het snelst bekomen van een resultaat. Ten derde werden volgens het superscalar sequencing principe onafhankelijk methodes automatisch verdeeld over de beschikbare cores, waardoor de CPU optimaal kan worden benut. Als laatste werd een methode voorgesteld waarbij recursieve methodes kunnen worden opgesplitst. Een hoge graad van genericiteit werd bekomen, waardoor prototypes hergebruikt kunnen worden met minimale aanpassingen. De code blijft leesbaar en overzichtelijk door het aanduiden van parallelle code met behulp van markeringen. Deze tool is open source en kan naar believen worden aangepast. Een voorstel is om een platform uit te bouwen, waarbij programmeurs hun eigen parallelle patterns kunnen delen met de community. In die zin blijft het model leven en kan de bibliotheek aan patterns zo modulair blijven groeien. De stap durven zetten naar parallel programmeren vergt dus wat energie maar de winst die eruit kan gehaald worden is enorm. Dit wil niet zeggen dat de programmeur er alleen voor staat: Naast de zelfstudie (door onder andere het joinen van communities of het volgen van tutorials) evolueert de maatschappij ook mee. Tegenwoordig is men reeds bewust van de noodzaak aan parallellisatie waardoor het slechts een kwestie van tijd is wanneer parallel coderen eerder als een basis competentie aanzien zal worden in plaats van een vrees. IV Optimal parallelization for writing and executing program code Samnang Nop*, Koen Casier**, Jonathan Spruytte*** * Master in electronics-ICT at the University of Ghent, Kortrijk. ** Ghent University – iMinds, Department of Information Technology (INTEC) Abstract—The main objective of this thesis was to develop a tool that allows the programmer to write and execute parallel program code in Java, while maintaining a clear object oriented structure that is easy and non-intrusive. Parallel execution can be done by either splitting tasks or data. These subparts are then encapsulated in a thread, which allows scheduling them on a specific CPU-core. A tool has been developed which allows the programmer to intuitively transform sequential code to a parallel equivalent: Using AspectJ and Java Reflections, the code can be captured at compile time with the use of annotations. The code is then transformed into a parallel pattern and injected into the applications. By doing this, the original code suffers minimal changes, which allows the programmer to focus on solving a project, rather then worrying about parallel context. With this model we have gained a performance increase in speed and in optimal use of physical cores. The developed prototypes can be used as a fully functional model, as well as an aiding tool for taking the first steps into the parallel programming world. Keywords— Parallel programming, Java, Object Oriented, Multi core, Code injection, Performance, Speed Profit disadvantage is that we have less control over our memory allocation and the physical access of system threads [4]. Transformation Sequential code Readable and safe code Easy syntax Maintaining performance Object Oriented model Parallel code Fig.1: Black box transformation of code In chapter II we will discuss how an intuitive transformation can be designed and which steps ought to be taken. Some of the results of the prototypes are then discussed in chapter III. In chapter IV we will reflect on our obtained goals by explaining how a programmer can use this model as an easy introduction to parallel programming. II. INTUITIVE PARALLELLISATION I. INTRODUCTION Nowadays, single core computers are disappearing while being replaced by multi core computers [1]. This change is due to socio-technological evolution, where performance acts as a main driver. Earlier, the focus was on the design of hardware, whereas multiple cores were developed to improve performance. Yet, at this moment it is also of concern to the software designer: to preserve performance, the programmer has to program applications that take full advantage of all hardware developments. Therefore, parallel programming was developed, in fact, there are many tools and programming coding languages (e.g. OpenMP [2], Par4All) that are designed specifically for parallelization. However, writing parallel code is associated with a number of challenges: 1) How does one write parallel code? 2) How can we transform a sequential algorithm to a parallel equivalent? 3) And what about the shared data? Data always has to be consistent, so that the results are always reliable [3]. The programmers should also keep an open view on concurrency (pieces of code that can be executed independently at the same time) [5]. Keeping track of all these aspects while programming can lead to distraction and loss of focus. In this paper we will discuss a model for the Java programming language that allows an easy and safe implementation of parallel code. Java is an object-oriented language: this is a paradigm where a system is built from objects. The main advantage of Java, is that it has a higher level of abstraction, and thus writing code in Java is relatively simple and intuitive. The major The process of intuitively transforming sequential to parallel code can be broken down into a succession of different steps: A. Analysis: marking regions of code, which are parallelizable. B. Transformation: replacement of sequential code to a parallel pattern. C. Injection: injecting and executing calculations on different cores. A. Analysis In this step, program code is examined by exploiting concurrency. Sometimes it is also necessary to rewrite sequential algorithms in such a way that it allows splitting data or tasks. Pieces of code that are independent can be isolated in a separate method. Using predefined annotations one can then identify parallel regions (the equivalent of compiler directive pragma from the OpenMP model). An annotation is intended to indicate that the code may be translated to a parallel equivalent (for example @Parallelfor, which means that one wants to transform a for-loop to a parallel loop). B. Transformation When compiling, the code was first checked the AspectJ compiler [6]. AspectJ is a tool that allows to dynamically change the behaviour of a method. In this application, the precompiler will scan the code and try to find annotated methods. This method is then, depending on the choice of annotation, replaced by an equivalent parallel pattern [8]. Design patterns are used to solve a common parallel problem on a generic and abstract manner. In other words, re-use of a design instead of code. The choice of pattern depends on how the algorithm or problem behaves in terms of task (divide & conquer, superscalar sequencing,..) and data (subdivision, search, absurdly for ...). C. Injection In this final step, the parallel code is injected into the program code. Java Reflections [7] can be used to capture method arguments from the original method, modify them, and re-inject them into the parallel method. This process of injection is based on the boilerplate model. Basically the sequential code won’t be executed, but instead replaced by a parallel equivalent. The parallel code won’t be noticeably in the original code, which preserves the readability of the code. In fig. 2, a summary of the design tool is displayed. This model forms the basis of the realized prototypes that are discussed in the following sections. The following test was performed: The speed of calculation of different amounts of threads was calculated. The matrix operation was performed 100 times. Running single threaded gives us an execution time of approximately 145ms. The parallel execution on 2 threads gives us an execution time that’s approximately 1.5 times faster. When running on 4 threads, the execution time is 2.5 times faster in comparison with the single threaded execution. The reason why 2 cores do not result in a speedup of 2 is due to 2 reasons: 1) Creating threads cause some overhead. 2) Amdahl’s law, which states that the speedup of a program using multiple processors in parallel computing is limited by the time needed for the sequential fraction of the program SortRacer. B. Sort Racer The following example (Sort Racer) is basically a variant of the racer. The goal is to sort a dataset as quickly as possible. This can be achieved by simultaneous running multiple sorting algorithms (Merge-, selection-, Insertion and bubblesort) over the available cores. When the fastest thread yields a result the other threads are then cancelled. The obtained results are shown below, within a logarithmic scale. Fig. 2: Intuitive parallelization III. PROOF OF CONCEPT To test the model several prototypes have been developed. In this chapter the results of the prototypes are discussed: The first prototype describes the parallel absurdly pattern where data is split across multiple cores to achieve an increase in execution time. The second prototype describes a racer where all of the available cores are used to calculate the same result. When one of the cores provides an answer, the other cores are then cancelled. A. Absurdly for One of the most common and general patterns is the absurdly for pattern. This pattern can be used to calculate a matrix multiplication. In programming language, a matrix is often represented as an (multi-dimensional) array. Calculations are straightforward, and can be done by iterating a for-loop. In order to perform a parallel execution, the arrayindices are used as the basis of the splitting mechanism. Fig. 3: Parallelfor speedup Fig. 4: Comparison of execution times of sorting algorithms. The dark dashed line shows the execution time of the RacerSort. The obtained results are quite plausible: sorting different datasets with a variable number of elements gives us the result that would be obtained with the use of its optimal sorting algorithm. Fig. 4: C. Case: Tower Defense Tower defense is a genre of real-time strategy video games, where the goal is to stop the creatures from reaching a specific point on the map by building a variety of different towers, which shoot at them as they pass. The game evolves by looping over a few phases: In phase 1 the creatures are moved towards the towers. In phase 2 each of the creatures calculates their distance in comparison with the towers. When the distance is within a certain reach, they’ll perform an attack (decrease in health points). In phase 3 each creature and tower will check their health points. When no points are left, the object is then removed from the game. The results of each phase is obtained by iterating through all object with the use of a for-loop. Hence, splitting these forloops (with the absurdly for method) enables parallelization. To test the performance we have executed the game with an increasing amount of creatures. Fig. 5: Tower Defense, calculation time of phase In the beginning, the execution time is substantially equal. As calculations are becoming heavier, the parallel execution on 4 threads gives us an execution time that’s approximately 2.89 times faster. One of the reasons why the expected profit rate of 4 does not apply is because the CPU enables hyper threading (which means there are only 2 physical cores, thus 4 virtual cores). A second reason is the expected overhead, which is obtained by creating a thread. FIG. 5: Fig. 6: Tower Defense, frames per second FIG. 6: When calculating the frame rate, we can notice a significant higher performance when running multi threaded (2 to 3 times higher frame rates). Note that, in this case, we have only parallelized the calculation cycles without bothering about the rendering phase. IV. CONCLUSION The programmer holds a big responsibility in obtaining performance by learning how to program multi core applications. The process of learning parallel programming code is often not easy: algorithms that are based on a sequential execution suddenly have to be replaced by less elegant solution in order to enable parallel execution. Furthermore, exploiting concurrency in code is something that demands experience in parallel coding by for example recognizing parallel patterns. As this thesis focused on the Object Oriented paradigm, the different behaviour of objects has to be kept in mind which makes the writing of parallel code much more trivial. The last extra attention goes to the memory: in parallel coding, keep data eventual consistent is a great challenge. The prototypes in this paper can be used as a fully functional model. It is also a useful tool that could help the programmer with his first steps into the parallel coding world. The awareness and importance of parallel coding is already here, and it will just be a matter of time, when parallel coding becomes a basic competence of every respected programmer. REFERENCES [1] Three reasons for moving to multicore (2009). Consulted on January 20, 2015 through http://www.drdobbs.com/parallel/threereasons-for-moving-to-multicore/216200386 [2] Intoduction to OpenMP (2013). Consulted on February 14, 2015 through https://www.youtube.com/watch?v=cMWGeJyrc9w [3] Philip A. Bernstein, Sudipto Das (2013). Rethinking Eventual Consistency [paper]. Microsoft Research Redmond [4] The Java Memory Model. Consulted on May 20, 2015 through http://shipilev.net/blog/2014/jmm-pragmatics/ [5] Java Concurrency and multithreading (2015). Consulted on February 14, 2015 through http://tutorials.jenkov.com/javaconcurrency/index.html [6] AspectJ (2015). Consulted on December 20, 2014 through https://eclipse.org/aspectj/ [7] Java Reflections. Consulted on December 23, 2014 through https://docs.oracle.com/javase/tutorial/reflect/ [8] Rian Goossens, Tom Van Steenkiste (2015). A Survey on Design Patterns and Skeletons in Parallel Programming [paper]. Faculty of Engineering And Architecture Ghent University Inhoudsopgave VOORWOORD& I! TOELATING&TOT&BRUIKLEEN& II! OVERZICHT& III! EXTENDED&ABSTRACT& V! INHOUDSOPGAVE& VIII! 1&&DE&NOOD&AAN&PARALLELLISATIE& 1! 2&&PERFORMANCE&ALS&DRIJFKRACHT& 3! 2.1! VAN&SINGLE>CORE&NAAR&MULTI>CORE& 3! 2.1.1! EVOLUTIE!OP!LAAG!NIVEAU:!HARDWARE! 2.1.2! EVOLUTIE!OP!HOGER!NIVEAU:!SOFTWARE!EN!DATA! 2.1.3! ZOWEL!HARDWARE!ALS!SOFTWARE!BEPALEN!PERFORMANCE! 2.2! HET&BEHOUDEN&VAN&PERFORMANCE& 2.2.1! SYMMETRISCH!VERSUS!ASYMMETRISCH!SYSTEEM! 2.2.2! EEN!EN!EEN!MAKEN!GEEN!TWEE! 2.2.3! BEHOREN!SINGLECCORES!DAN!TOT!HET!VERLEDEN?! 4! 6! 7! 8! 8! 9! 10! 2.3! UITDAGINGEN&VOOR&DE&PROGRAMMEUR& 2.3.1! DE!VREES!VAN!DE!PROGRAMMEUR! 2.3.2! DE!MOEILIJKHEID!AAN!JAVA!(OBJECT!ORIENTED)! 2.3.3! OPTIMALE!PARALLELLISATIE! 11! 11! 12! 12! 3&&INTUITIVE&PARALLELLISATIE&CODE& 13! 3.1! PROGRAMMEER>TECHNISCHE&VOORKENNIS& 3.1.1! MULTICTHREADING! 13! 13! 3.1.2! GEDRAG!VAN!DATAOBJECTEN!EN!PRIMITIEVEN! 3.1.3! ASPECTJ!RUNTIME!BIBLIOTHEEK! 3.1.4! JAVA!REFLECTIONS! 15! 15! 18! VIII 3.2! SNELHEIDSWINST&BEHALEN& 3.2.1! PARALLELLE!DESIGN!PATTERNS! 3.2.2! SKELETONS! 3.3! OPENMP& 19! 20! 25! 25! 3.3.1! GEMAKKELIJK!EN!INTUÏTIEF! 3.3.2! ROBUUST!EN!KRACHTIG! 3.4! ONTWERP&VAN&AUTOMATISCHE&PARALLELLISATIE&TOOL& 3.4.1! STAGES!TOT!AUTOMATISCHE!PARALLELLISATIE! 3.4.2! BESTAANDE!TOOLS! 26! 26! 27! 28! 29! 4&&JAVA&IMPLEMENTATIE& 31! 4.1! PROCEDUREEL&NAAR&OBJECT&ORIENTED&MODEL& 31! 4.1.1! WAAROM!PORTING!NIET!EENVOUDIG!IS! 4.1.2! REALISATIE!VAN!EEN!MODEL! 4.2! OPBOUW&VAN&PROTOTYPES& 4.2.1! ABSURDELY!FOR! 4.2.2! SORTRACER! 4.2.3! RUPS! 4.3! CASE&1&:&TESS>MODULES& 4.4! CASE&2&:&TOWER&DEFENSE& 31! 32! 34! 34! 38! 41! 43! 46! 4.5! CASE&3&:&SUDOKUSOLVER& 4.6! EINDBESPREKING&PROTOTYPES& 4.6.1! GRADATIE!VAN!PARALLELLISATIE! 4.6.2! REALISATIE!VAN!HET!MODEL! 4.6.3! STAGNATIE!IN!SNELHEID!EN!OVERHEAD! 51! 56! 56! 56! 57! 5&&TOEKOMSTBEELD& 58! 5.1! VERBETEREN&VAN&HET&MODEL& 58! 5.1.1! AANVULLEN!VAN!PATTERNS! 5.1.2! SCALABILITY! 5.1.3! ARTIFICIËLE!INTELLIGENTIE! 5.2! CONCLUSIE& 58! 59! 60! 61! REFERENTIES& 63& BIJLAGES& 65! ! IX Lijst van figuren 3! 4! 5! 5! 6! 7! FIG!2C1!:!EVOLUTIE!VAN!HET!AANTAL!FYSIEKE!CORES!OP!EEN!INTEL!XEON!CPU’S! FIG!2C2!:!MAXIMALE!FREQUENTIE!VAN!INTEL!CPU’S! FIG!2C3!:!VERMOGENVERBRUIK!SINGLE!CORE!MET!F![2]! FIG!2C4!:!VERMOGENVERBRUIK!DUOCORE!MET!F/2![2]! FIG!2C5!:!EVOLUTIE!VAN!HET!AANTAL!TRANSISTORS!OP!EEN!CPU!(INTEL)! FIG!2C6!:!SAMENVATTING!EVOLUTIE!SINGLECCORE!NAAR!MULTICCORE! FIG!2C7!:!IPHONE!ASSYMMETRISCHE!MULTICCORE!CPU!!!!!!! FIG!2C8!:!MAC!PRO!SYMMETRISCHE!MULTICCORE!CPU! FIG!2C9!:!BLACKBOX!TRANSFORMATIE! !FIG!3C1!:!BOILERPLATE!MODEL! FIG!3C2!:!FORKCJOIN!MODEL,!ROOD=HOOFDTHREAD,!GEEL=SPLITSING!OVER!THREADS.! FIG!3C3!:!SPLITSING!VAN!ABSURDELY!PARALLEL!PATTERN! FIG!3C4!:!DIVIDE!EN!CONQUER!PATTERN! FIG!3C5!:!SUPERSCALAR!SEQUENCING! FIG!3C7!:!SUBDIVISION!IN!DATA! FIG!3C8!:!SEARCH!IN!DATA! FIG!3C9!:!SHARED!QUEUE! FIG!3C9!:!GEOGRAPHIC!DATA! FIG!3C10!:!VERGELIJKING!BESTAANDE!PARALLELLE!TOOLS! FIG!4C1!:!SCHEMA!PARALLELLISATIE!TOOL!IN!JAVA! 9! 12! 15! 20! 21! 22! 22! 23! 24! 24! 25! 29! 33! FIG!4C2!:!DATA!GEBASEERDE!PATTERN!(SUBDIVISION!IN!DATA)! FIG!4C3!:!PROTOTYPE!1,!PARALLELFOR!SPEEDUP!GRAFIEK! FIG!4C4!:!PROTOTYPE!2,!RACERSORT!SPEEDUP!GRAFIEK! FIG!4C5!:!PROTOTYPE!2,!RACERSORT!SPEEDUP!GRAFIEK,!FOCUS!OP!DATASETGROOTTE! FIG!4C6!:!BOOMSTRUCTUUR!VAN!TIMEFUNCTIONS! FIG!4C6!:!OPDRIJVEN!VAN!HET!AANTAL!SPRITES!IN!TOWER!DEFENSE! FIG!4C7!:!TOWER!DEFENSE,!CALCULATION!TIME!SINGLEC!VERSUS!MULTICTHREADED! 6 FIG!4C7!:!TOWER!DEFENSE,!FPS!SINGLEC!VERSUS!MULTICTHREADED ! FIG!4C9!:!SUDOKU!BRUTECFORCE,!DEPTHCFIRST! FIG!4C11!:!SUDOKU!BRUTECFORCE,!BREADTHCFIRST! FIG!4C12!:!VERKLARING!VERSCHIL!IN!RESULTATEN! FIG!5C1!:!MODULAIRE!OPBOUW!VAN!PARALLELLE!PATTERNS,!BOILERPLATE!MODEL! FIG!5C2!:!VOORSTEL!VOOR!SCALABILITY!IN!HET!MODEL!M.B.V.!MPI! 34! 37! 39! 40! 44! 49! 50! 50! 53! 53! 55! 59! 59! X 1 De nood aan parallellisatie Het tijdperk van de single-core computers begint stilaan te verdwijnen. Vroeger streefden CPU-fabrikanten steeds meer en meer naar het verhogen van klokfrequenties. Tegenwoordig gebeurt dit wat minder en wordt er meer geïnvesteerd in de ontwikkeling van duo-cores, quad-cores, octa-cores,… kortom multi-cores. Maar vanwaar deze trend? Wat is nu de exacte reden van het verdwijnen van de single-cores? Waarom verhoogt men de kloksnelheden niet verder? En vooral: welke impact heeft dit uiteindelijk op de hardware- en softwaredesigners? Deze verandering is in zekere zin het resultaat van de maatschappelijke en technologische evolutie, waarbij performance centraal staat. Op maatschappelijk vlak, vooral in deze Westerse cultuur, is tijd een belangrijke force: alles moet zo snel mogelijk gebeuren op een meest efficiënte manier. Computerkracht is daarom een onmisbare tool! In die zin creëert de maatschappij een zekere druk op de technologische industrie waardoor de vraag naar performance resulteert in de ontwikkeling van multi-cores (paragraaf 2.1). Terwijl voordien het verbeteren van performance eerder lag bij de hardware-designer is deze taak nu ook een zorg van de software-designer: het uitwerken en implementeren van complexe applicaties die optimaal gebruik maken van alle cores zal stilaan een basiscompetentie worden van iedere gerespecteerde programmeur. In deze thesis staat de (object oriented) programmeur dan ook centraal en gaan we op zoek naar een manier waarop we bestaande code (semi-)automatisch kunnen analyseren en herschrijven/herwerken naar parallelle code. Het schrijven van parallelle code gaat echter gepaard met enkele uitdagingen. Parallel programmeren is op zich niet nieuw en dat bewijst zich in de tal van tools en programmeertalen die specifiek ontworpen zijn voor parallellisatie. Het probleem van nieuwe parallelle programmeertalen tweeledig. Ten eerste is er juist het feit dat het een nieuwe programmeertaal is. Bestaande projecten zouden volledig herschreven moeten worden en omwille van diverse redenen is het zelfs al niet mogelijk om eenzelfde applicatie in een andere taal te schrijven. Daarnaast wordt vaak bij een parallelle programmeertaal sterk geschreven vanuit de parallelle constructies, waarbij andere 1 bestaande logische structureringen van bestaande programmeertalen (procedurele structuren, object oriëntatie, aspect oriëntatie, …) overboord worden gegooid. Daarom richt deze thesis zich op niet intrusieve tools voor parallellisatie. Het grote voordeel hieraan is dat de programmeur kan blijven schrijven in zijn vertrouwde taal. Echter, tools vergen ook een zekere inspanning aangezien de programmeur eerst vertrouwd moet geraken met de syntax om daarna parallelle code te schrijven. Dit tweede vormt een grote belemmering: Hoe schrijft men parallelle code? Niet alle algoritmes zijn namelijk splitsbaar! Vaak moet er voldoende gedacht worden aan het herschrijven van een algoritme dat in eerst instantie geen voordeel brengt in sequentieel programmeren, maar wel een enorm voordeel biedt in parallel programmeren. En hoe zit het nu met data die gedeeld wordt? Deze moet immers altijd consistent zijn zodanig dat we de correcte resultaten bekomen. Ook zou de programmeur een oog moeten hebben voor concurrency (stukjes code die onafhankelijk van elkaar kunnen worden uitgevoerd op eenzelfde ogenblik). Een programmeur die steeds rekening moet houden met deze zaken verliest focus over zijn project. In deze thesis richten we ons dan ook op een standaard high-end object georiënteerde taal, en praktisch zullen we ons richten op Java. Dit is een paradigma waarbij een systeem wordt opgebouwd uit objecten: eenvoudig gezegd bestaat een object uit bepaalde parameters waarmee zijn gedrag kan worden aangepast. In die zin is Java een hogere taal, wat wil zeggen dat we op een relatief grote afstand zitten van onze fysieke hardware. Het voordeel is dat we een kleiner abstractieniveau hebben, waardoor het schrijven in Java relatief eenvoudig en intuïtief is. Het grote nadeel hieraan is dat we veel minder controle hebben over onze data in het geheugen. Daarnaast wordt Javacode uitgevoerd op een virtual machine (JVM), waardoor we in principe virtuele adressen benaderen. Wat we willen bereiken in de thesis is een zo hoog mogelijke graad aan parallellisatie, gepaard met een minimale programmeer effort voor de programmeur. Een tool ontwikkelen die optimale parallellisatie brengt aan de programmacode, waarbij de focus kan blijven op het oplossen van een project en niet op de focus van parallelle syntax en patronen. 2 2 Performance als drijfkracht 2.1 Van single-core naar multi-core Sinds 2005 zijn de eerst commerciële multi-core CPU’s op de markt gebracht. Geleidelijk aan verdwijnen single-cores en zien we steeds meer en meer desktopcomputers, laptops, smartphones en tablets die multi-cores omvatten. Deze trend zal zich met grote waarschijnlijkheid verderzetten. Om dit te verstaan bespreken we de evolutie vanuit twee kanten : we doen dit door eerst de bril van de hardware-designer op te zetten om daarna te zien welke impact dit op de software-designer heeft. Beiden hebben als grootste gemene deler performance als drijfkracht. Aantal Cores op de CPU (Intel Xeon Family) 0 2 4 6 8 10 12 14 16 18 20 Q2'15 Q3'14 Q1'14 Q3'13 Q2'12 Q1'12 Q3'09 Q2'06 Aantal!fysieke!cores! Fig 2-1 : Evolutie van het aantal fysieke cores op een Intel Xeon CPU’s1 Opmerking: Een aantal CPU’s hebben meer dan 20 cores. 1 Datapunten opgehaald via http://ark.intel.com op 24 mei 2015 als XML-file. 3 2.1.1 Evolutie op laag niveau: hardware Het spreekt voor zich dat hoe hoger de kloksnelheid, hoe meer verwerkingen er gebeuren per tijdseenheid, en dus hoe performanter het systeem zal werken. We zien echter dat de laatste jaren de kloksnelheid blijft hangen tussen de 3 en 4 GHz. De keuze van de fabrikanten om te investeren in onderzoek en productie van multi-cores heeft als basis drie invalshoeken: Fysische beperkingen, economisch aspect en technologische vooruitgang. [1] Maximum&freunPe&[MHz]& 4500 4000 3500 3000 2500 2000 1500 1000 500 0 0 5 10 15 20 25 30 35 40 Type&Intel&CPU& Fig 2-2 : Maximale frequentie van Intel CPU’s2 Fysische beperkingen Naarmate de kloksnelheid toeneemt, gaat de stroom zich anders gaan gedragen. Signalen worden meer verstoord waarbij verwerking van diezelfde signalen moeilijker en minder betrouwbaar wordt. Uit deze aspecten hebben CPU-fabrikanten gekozen om alternatieve oplossingen te vinden om de performance van een systeem te kunnen opdrijven. Praktisch betekent dit ook dat bij een lagere klokfrequentie, de signalen betrouwbaarder zijn. Economisch aspect Het opdrijven van de kloksnelheid gaat gepaard met een hoger energieverbruik. Dit vermogen kan worden uitgedrukt als de verhouding van het geleverde werk over een bepaalde tijdseenheid: hoeveel keer per seconde we het circuit oscilleren. Na afleiding bekomen we volgende formule : P = CV2f (waarbij C=capaciteit, V=spanning, f=kloksnelheid) 2 Zie bijlage 1 voor datapunten 4 Dit wil zeggen dat bij een zekere frequentie f één enkele core een vermogen vraagt van CV2f. Wanneer we nu het werk zouden verspreiden over twee cores met elk een frequentie f/2, dan kan de spanning voor elke core lager worden en finaal kunnen we dan aantonen dat er een reductie is van 60% in vermogenverbruik. Een lager vermogenverbruik leidt ook tot een lagere warmteontwikkeling, en daaraan gekoppeld ook minder koelingsproblemen en energie verbruikt bij deze koeling. Vanuit verschillende economische aspecten is dit dan ook meer verantwoord. Fig 2-3 : Vermogenverbruik single core met f [2] Fig 2-4 : Vermogenverbruik duocore met f/2 [2] Technologische evolutie De fabricagetechnieken verbeteren echter nog steeds. De vooruitgang naar steeds kleinere transistors laat ons toe op telkens kleinere CPU’s te bouwen. Zonder de overgang naar multi-core architecturen leidt dit tot hogere energiedissipatie per oppervlakte-eenheid. Naar aanleiding van deze oppervlakte-winst, kan men dus op eenzelfde oppervlakte van een single-core meerdere cores plaatsen met dezelfde capaciteiten. Dit om als doel de performance te kunnen blijven behouden. 5 #transistoren&[miljoenen]& 2500 2000 1500 1000 500 0 1980 1985 1990 1995 2000 2005 2010 2015 Jaar&[y]&& Fig 2-5 : Evolutie van het aantal transistors op een CPU (Intel)3 Deze drie bovenstaande invalshoeken hebben ervoor gezorgd dat de trend van singlecore naar multi-core de toekomst is. Hardware-designers (met name CPU) volgen momenteel volledig deze strategie. 2.1.2 Evolutie op hoger niveau: software en data We leven in een tijdperk waarin praktisch alles gedigitaliseerd moet worden. Het vergt in zekere zin moeite en energie om gegevens te digitaliseren, maar de voordelen die we eruit halen overtreffen zeker deze overhead: simuleren van de fysische wereld, opstellen van statistische modellen, voorspellen van trends, vinden van bepaalde gegevens uit een database met honderdduizenden entries, digitale beeldverwerking, … De tijd waarin we dit resultaat kunnen bekomen kan door een menselijk brein niet worden overtroffen. Het spreekt voor zich dat hoe meer gegevens er moet worden verwerkt, hoe tijdsintensiever dit wordt. De vraag naar een constant stijgende performance blijft reëel. Op dit vlak kunnen we twee invalshoeken bekijken: splitsbaarheid in big data en concurrency in software. Partitioneren van Big Data Een enorme hoeveelheid van data bevindt zich, al dan niet geordend, op servers, de cloud of lokale computers. Het verwerken van deze data, met eender welk doel dan ook, vraagt rekenkracht. Hoe meer gegevens, hoe meer klokcycli er nodig is van de CPU, dus hoe performanter een systeem moet zijn. Wanneer kloksnelheden niet meer kunnen worden verhoogd is het logisch gevolg hiervan het opdelen van big data in kleinere segmenten of partities 3 Zie bijlage 1 voor datapunten 6 Concurrency in software Praktisch in alle software-applicaties die we dagelijks gebruiken worden meerdere processen uitgevoerd. Nemen we als voorbeeld de mobiele facebook applicatie: de app is in staat om continu de huidige locatie op te vragen, video’s af te spelen, de live feed up-to-date houden en eventueel nog conversaties te houden met andere gebruikers. Natuurlijk willen we dit op hetzelfde moment doen, zonder telkens te wachten tot een ander proces is beëindigd. In dit geval vraagt software om concurrency. 2.1.3 Zowel hardware als software bepalen performance Enkele jaren geleden was het zo dat verbetering in performance steeds werd gevraagd aan de hardware-fabrikanten. Programmeurs schreven hun code sequentieel en naarmate de kloksnelheden verhoogden, verhoogde ook de performance van hun applicatie. De (logische) verplichte opkomst van multi-core computers heeft ervoor gezorgd dat de verbetering in performance nu ook in handen ligt van de programmeur. En dit brengt enorm grote uitdagingen met zich mee. Waarom er nu een wederzijds contract moet gebeuren tussen hardware en software werd reeds besproken in paragraaf 1.2. Multilecore Singlecore Software Big data Veel meer gegevens om te verwerken Hardware Vraag naar hogere performance Telkens verhogen van kloksnelheid Multitasking apps Veel verschillende functies op eenzelfde ogenblik Exponentiele stijging in energieverbruik Veel warmte ontwikkeling Storing in signalen Verhoging van frequentie stagneert Optimale parallellisatie Dankzij technologische evolutie Programmeurs moeten de stap wagen naar multicore programmeren - Aanpassen van algoritmes - Concurrency exploiten (patterns) - Joinen van communities Steeds kleinere transistoren laten toe om op eenzelfde oppervlakte veel meer rekenkracaht te beoefenen. Vraag naar hogere performance Plaatsen van meerdere CPU’s op een kleinere oppervlakte. Fig 2-6 : Samenvatting evolutie single-core naar multi-core 7 2.2 Het behouden van performance Bij het uitschrijven van programmacode is het historisch altijd belangrijk geweest om dit zo intuïtief mogelijk te maken voor de programmeur. Vanuit machinetaal (instructiecodes in bytes of words) is er daarom een evolutie gebeurd richting een imperatieve talen waarbij instructies worden vertaald in logische woorden en parameters. Vanuit imperatieve talen is dit dan overgegaan naar procedurele talen, waarbij herbruikbare en zelf te definiëren procedures worden uitgeschreven. Na deze stap zijn veel verschillende paradigma’s hierop verder gegaan, waarbij object oriented talen de belangrijkste plaats hebben ingenomen: Hierbij wordt het probleem in herbruikbare concepten met data, methodes en interacties voorgesteld. In essentie zijn al deze talen niet ontwikkeld met parallellisatie in het achterhoofd en zij stellen dan ook intrinsiek sequentiële uitvoering voor. Dit mapt perfect op single-core, maar maakt geen gebruik van verschillende cores. Het programmeren en optimaal benutten van een multi-core computers vraagt in die zin wat meer inspanning. We kunnen dit vergelijken met het schrijven van een verhaal: Als enige auteur bepaal je zelf volledig hoe de vorm en structuur van het verhaal eruit ziet. Wanneer meerdere personen zouden werken aan eenzelfde verhaal dan is er nood aan afstemming. Er moet namelijk worden afgesproken hoeveel hoofdstukken er worden geschreven, wie welk hoofdstuk schrijft, wie de hoofdstukken samenbrengt… Deze analogie kan worden gemaakt bij het programmeren van een multi-core systeem. Een goeie communicatie en coördinatie is nodig om het geheel zo efficiënt mogelijk te laten verlopen. 2.2.1 Symmetrisch versus asymmetrisch systeem Multi-core op zich is niet nieuw, deze technologie gebruiken we eigenlijk al jaren! Als voorbeeld nemen we de smartphone, deze bevat meerdere CPU’s: een baseband-CPU, een applicatie-CPU, een GPS-CPU, … Een master-CPU zorgt dan voor de coördinatie tussen deze verschillende types CPU’s. Een dergelijk systeem wordt een asymmetrisch systeem genoemd: meerdere CPU’s die elk een eigen, dedicated functie hebben. Daartegenover staat een symmetrisch systeem: meerdere cores die op een gelijke manier zijn aangesloten op het hoofdgeheugen en waarbij iedere core evenwaardig is. Daarmee willen we meer klok-cycli realiseren in een bepaalde tijd om zo de performance te verhogen ondanks de stagnatie van stijgende kloksnelheden. Zo’n symmetrisch systeem is wat wij in deze scriptie onder multi-core-technologie verstaan. Wat is nu de relevantie binnen het domein van performance? Wel, zoals we reeds hebben aangekaart, is iedere CPU bij een asymmetrisch systeem zodanig ontworpen om één enkele specifieke taak uit te voeren. In feite gaat de programmeur dus verschillende 8 single-core toepassingen coderen naargelang de CPU en is er in die zin geen sprake van multi-core programmeren. Bij een symmetrisch systeem zal een goeie programmeur rekening houden met het aantal cores en zijn programma zodanig schrijven dat er simultaan meerdere cores worden aangesproken. Fig 2-7 : iPhone assymmetrische multi-core CPU Fig 2-8 : Mac Pro symmetrische multi-core CPU 2.2.2 Een en een maken geen twee De vraag is nu of een multi-core applicatie twee maal sneller zal draaien op een duocore of vier maal sneller op een quad-core? Met andere woorden: loopt de snelheid recht evenredig met het aantal cores? Jammer genoeg werkt dit niet zo. Ook hier is de verklaring te wijten aan zowel de hardware als de software. Hardware Op vlak van hardware is de bandbreedte van het geheugen de bottleneck. Dit is de hoeveelheid data die men per tijdseenheid kan versturen van en naar de CPU. Wanneer we kijken naar een single-core, dan wordt de volledige bandbreedte toegewezen aan die CPU. Het spreekt voor zich dat hoe meer CPU’s er worden aangesloten, hoe minder de gemiddelde bandbreedte per CPU wordt. Een oplossing hiervoor zijn de cachegeheugens[3]. Cachegeheugens zijn in principe heel snelle privégeheugens voor elke CPU. Op die manier hoeft de CPU geen rekening te houden met het delen van zijn geheugen. Een probleem dat hier wel wordt geïntroduceerd is de consistentie in data. Er kunnen namelijk verschillen ontstaan in het hoofdgeheugen en het cachegeheugen. Op zich zijn deze verschillen geen probleem, 9 maar er moet wel worden gegarandeerd dat wanneer een bepaalde CPU(a) data opvraagt, dat net ervoor werd gewijzigd door een andere CPU(b), dat deze data correct is. Deze controle zorgt ervoor dat we ook hier een bepaalde vertraging verkrijgen. Software De uitdaging bij software ligt vooral bij de opsplitsbaardheid. Algoritmes moeten in een zodanige zin worden geschreven dat die onafhankelijk en verdeeld kunnen worden uitgevoerd. Neem bijvoorbeeld de multiplicatie van twee matrices. Deze bewerking is perfect op te splitsen, maar om het resultaat van een cel (x,y) te bekomen is het nodig om van de ene matrix de data van x-rij te hebben, en van de andere matrix de data van de y-kolom. Wanneer berekeningen uitgevoerd zijn, moeten die ook nog terug worden samengevoegd (reduce). Zowel het splitsen als het samenvoegen vraagt enige overhead, en dus ook vertragingen. Daarnaast is veel software object georiënteerd sequentieel geschreven. Dit wil zeggen dat er vaak nauwelijks of geen rekening wordt gehouden met de architectuur van specifieke CPU’s, meer bepaald de instructieset (RISC versus CISC versus VLIW). Aangezien dit geen significante relevantie heeft, gaan we hier niet verder op in deze thesis. 2.2.3 Behoren single-cores dan tot het verleden? Na alles wat we tot nu toe besproken hebben, kunnen we de vragen stellen of singlecore nu tot het verleden behoort. Het antwoord hierop is nee. Er zijn tal van applicaties en devices waarvoor single-core CPU’s meer dan volstaan. Het uitgangspunt hierin is dat, wanneer het systeem reeds performant genoeg draait, er geen verbeteringen op vlak van snelheid hoeven te gebeuren. Nemen we als voorbeeld een mediaspeler met een typische TriMedia CPU van Phillips. Deze processor is in staat om heel efficiënt video en geluid te decoderen (optimale instructieset VLIW en enkel super-scalaire pipelines). Dit volstaat om media vloeiend af te spelen zonder storing naar de gebruiker toe. Het spreekt voor zich dat multi-core hier geen zin heeft. (Tenzij er natuurlijk simultaan meerdere mediafragmenten moeten worden berekend, maar dit brengt ons dan terug op een asymmetrisch systeem). Een tweede aspect is ook het economisch aspect: aangezien een single-core voldoet aan al onze noden, is het niet nodig om nog te investeren in het ontwerp van een multi-core systeem. Technologische vooruitgang is namelijk een enabler, niet perse een verplichting. 10 2.3 Uitdagingen voor de programmeur Zoals eerder werd besproken worden er van zowel de software als hardware moeite gedaan om steeds te streven naar het meest performante. Hoewel deze evoluties elkaar wederzijds hebben beïnvloed, merken we toch dat er bij de doorsnee software programmeur een zekere achterstand is. Dit heeft vooral te maken met ons denkvermogen: Alhoewel we wel parallel kunnen denken is het een andere zaak om effectief zo’n handelingen te vertalen in code-talen. Er zijn immers heel wat implicaties die een systeem moeten ondergaan zoals het beheer van geheugens. We bevinden ons in een tijd waarin dit aspect van programmeren niet meer te negeren valt. Zelfs amateur programmeurs hebben waarschijnlijk al ooit gebotst op het woord multi-threading. En dit is maar normaal ook, aangezien parallel programmeren in feite al jarenlang wordt toegepast op grootschalige projecten. Denk maar aan gigantisch grote wetenschappelijke berekeningen van NASA, de rendering van animatiefilms of de snelheid waarmee google zoekresultaten worden weergegeven. Dit alles is mogelijk dankzij de enorme snelheidswinst van een multi-core systeem. Voor big companies is dit al een ingebakken routine waarbij de investering in expertise en specifiek ervaren programmeurs zinvol is. Maar nu bevinden we ons in een tijdperk aan waarin parallel of multi-core computing ook een zorg is van de gewone programmeur. Deze thesis richt zich vooral op volgende vragen: Hoe kunnen we de instap bij multicore programmeren verkleinen? Hoe kunnen we parallel computing introduceren bij de programmeur op een manier zodanig dat de focus blijft op het oplossen van het probleem, en minder op het zoeken naar syntax en codeertechnieken van parallel coderen. 2.3.1 De vrees van de programmeur Een eerste aanraking met parallel programmeren is niet gemakkelijk. Bij een eerste verkenning worden termen als multi-threading, concurrency en data-dependency vaak aangekaart. Talen als C, C++, C# en Java hebben reeds support voor multi-threading. Maar bij vele programmeurs begint hier de vrees. Het is iets nieuws, iets waar men nog nooit echt mee heeft geprogrammeerd. Ik weet nog goed wat een docent in mijn bachelor-jaar zei toen we de taak kregen om een bepaalde encryptie te implementeren met een zo grote mogelijke efficiëntie: “Jullie mogen dit ook multi-threaded uitvoeren, ten minste als jullie ooit al multi-threaded hebben geprogrammeerd. Indien niet, dan zou ik dit niet aanraden…” Aangezien ik dit inderdaad nooit eerder in mijn opleiding heb gezien, heb ik dit dan wijselijk genegeerd. “Wat maken die 280ms snelheidswinst nu uit?”, dacht ik waarschijnlijk. Als ik er nu terug op denk, besef ik dat de huidige mentaliteit aangepast zou moeten worden. 11 Het schrijven van parallelle code is inderdaad een uitdaging. Maar zelf werd er weinig aandacht gegeven in de opleiding. Eenmaal de syntax en basisprincipes gekend zijn, ligt de uitdaging meer in het probleemoplossend denkvermogen van de programmeur en ook in het in rekening houden met geheugens. 2.3.2 De moeilijkheid aan Java (Object Oriented) De taal waarin deze thesis wordt besproken is Java. Java is een veelgebruikte taal, die meer en meer terug te vinden is in high-performance applicaties. De voordelen van Java tegenover bijvoorbeeld C en Fortran zijn de standaardbibliotheken, veiligheid en het consistent datamodel [5], overdraagbaarheid naar andere systemen en voor sommige onder ons ook het object oriented model. Het grote nadeel van de taal is, dat het een high-level taal is: zie het als een hiërarchisch model waarbij onderaan de fysieke hardware zit en hoe hoger men gaat, hoe verder men verwijderd wordt van de hardware en hoe minder controle men erover heeft [4]. Java is zodanig “hoog” dat het draait op een JVM (Java Virtual Machine). Ook hebben we als Java-programmeur zelf heel weinig controle over het gedrag van verschillende datatypes (enerzijds maakt dit de taal wel veiliger, anderzijds kan dit voor de nodige frustraties zorgen). We kunnen kortom stellen dat het gebrek aan controle over onder andere de fysieke locatie van data en het aanspreken van fysieke cores een groot struikelblok vormt indien we optimale parallellisatie willen realiseren. 2.3.3 Optimale parallellisatie Het doel is om uiteindelijk een manier te vinden waarmee de programmeur met groot gemak een reeds bestaande code kan omvormen naar veilige, parallelle, object oriented code. We mikken op een zo laag mogelijke instap en een zo groot mogelijke performance-verhoging. Ook moet er ruimte worden voorzien voor eigen inbreng van de programmeur zodanig dat de programmeur stap voor stap zelf parallelle code leert analyseren en tweaken. In het volgende hoofdstuk gaan we op zoek naar bestaande tools en duiken we dieper in op de studies die reeds gedaan werden op vlak van parallellisatie, deze zullen de basis vormen van een eigen implementatie voor optimale parallellisatie van programmacode. Het onderstaand schema (fig. 2-9) toont aan hoe deze translatie van sequentiele naar parallelle code idealiter kan worden gedaan. Fig 2-9 : blackbox transformatie 12 3 Intuitive parallellisatie code 3.1 Programmeer-technische voorkennis Alvorens effectief over te gaan naar het ontwerp van een tool die sequentiele programmacode kan omvormen naar parallelle code is het nodig om enkele basisconcepten te bespreken die parallellisatie mogelijk maken. In deze paragraaf wordt er stil kort gestaan bij de term multi-threading. Aangezien deze thesis zich richt op de programmeertaal Java zal er ook het gedrag besproken worden van dataobjecten en dataprimitieven. Java Reflections laat toe om! namen van klasses, methodes en argumenten on runtime op te vangen zonder enige voorkennis van de code. Dit is een heel krachtige tool aangezien hiermee genericiteit wordt geëxploiteerd. Als laatste onderdeel wordt de AspectJ runtime bibliotheek bekeken en wordt het belang van een boilerplate model aangehaald met behulp van code-injectie. 3.1.1 Multi-threading Multi-threading [6] is het simultaan uitvoeren van meerdere taken. Men kan dit het best voorstellen door verschillende taken die elk apart geïncapsuleerd zijn een pakje. Wanneer er één enkele core aanwezig is, zullen die pakjes opgedeeld worden in kleinere stukjes en elk afwisselend worden uitgevoerd op diezelfde core. In dat opzicht is er niet echt een simultane uitvoering, maar aangezien de afwisseling zodanig snel gebeurt, wekt dit wel de illusie ervan op. Wanneer er meerdere cores aanwezig zijn is parallellisatie mogelijk: elk pakketje wordt aangewezen aan een specifieke core. In dit geval is er wel werkelijke simultane uitvoering: de taken worden parallel uitgevoerd. Merk op: de term multitasking wordt in de Java vaak ook als synoniem gebruikt voor multi-threading. Oorspronkelijk is er een klein nuance verschil tussen deze twee. Multitasking speelt in op het OS-niveau: de mogelijkheid om meerdere processen of 13 applicaties te simultaan te runnen. Multi-threading past ditzelfde concept toe, maar dan op applicatieniveau (in een proces meerdere threads starten). Java heeft standaard een eigen multi-threading bibliotheek. Op zich is het niet zo moeilijk om een thread aan te maken. Runme.java public class Runme extends Thread{ private String naam; public Runme(String threadName) { super(threadName); this.naam = naam; } public void run() { System.out.println(naam); } } main.java public class main { public static void main(String[] args) { draad reeksA = new runme("taakA”); draad reeksB = new runme("taakB”); reeksA.start(); reeksB.start(); } } Een klasse kan worden aangemaakt die op zich van Thread overerft. Dit object kan daarna als thread worden uitgevoerd door het aanspreken van de methode start(). Deze methode voert opeenvolgend de methode run() uit in de aangemaakte klasse. Data consistentie Meerdere threads die simultaan lopen kunnen op hetzelfde ogenblik dezelfde object of dataprimitieve aanspreken. Indien er geen zorgvuldige organisatie is, dan kan er niet worden verzekerd dat de gelezen of geschreven data betrouwbaar is. Dit fenomeen wordt racing genoemd: de data wordt afhankelijk van de volgorde waarin deze wordt gelezen of geschreven door een bepaalde thread. Om dit op te lossen is er synchronisatie nodig. Het mechanisme hierachter zorgt ervoor dat bepaalde objecten geblokkeerd kunnen worden zodat ze slechts door één enkele thread kunnen worden gebruikt. Een mogelijk probleem dat hieruit kan ontstaan is deadlock: dit is het fenomeen waarbij een bepaalde thread ‘A’ wacht op het vrijkomen van een resource die vast zit in thread ‘B’. Thread ‘B’ wacht echter op zijn beurt op een resource 14 uit thread ‘A’ waardoor het proces niet meer kan doorgaan. Dit probleem staat welgekend als het filosofenprobleem [7]. 3.1.2 Gedrag van dataobjecten en primitieven Alle gegevens in Java zijn eigenlijk objecten. Deze objecten bevatten dan naargelang het datatype primitieven of een combinatie van primitieven. Het moeilijke aan Java is dat objecten worden doorgegeven als reference, en die reference op zich worden doorgegeven als value. Dit is een heel belangrijk aspect in de studie naar optimale parallellisatie. Dit maakt delen en clonen van gegevens in Java heel wat moeilijker. Het grote nadeel is dus dat we eigenlijk geen exacte controle hebben over het gedrag van data en waar deze zich fysiek bevindt in het geheugen (pointers in C [8]). Hoe hiermee wordt omgegaan komt nog terug in hoofdstuk 4, wanneer de eerste prototypes worden gebouwd. 3.1.3 AspectJ runtime bibliotheek AspectJ [9] is een plugin waarmee at runtime zogeheten pointcuts (punten in de code waarin een snede kan gemaakt worden) worden afgevangen. Deze pointcuts kunnen methodes met een specifieke signatuur, specifieke parameters en parameterwaarden, of geannoteerde code zijn. Deze pointcuts kunnen worden opvangen om daarna vòòr, tijdens of na de oproep van deze pointcut een stuk code te injecteren. Op die manier wordt het mogelijk het gedrag aan te passen van programmacode. Het aanduiden van een specifieke methode gebeurt in onze code met behulp van annotations. De te injecteren code wordt een advice genoemd. Dit principe staat welgekend onder de naam boilerplate model. 1) Oorspronkelijke code 2) Toevoeging annotations (pointcuts) 3) Injectie van code Fig 3-1 : Boilerplate model 15 Advice Een advice is eigenlijk een methode in de AspectJ klasse die kan worden opgeroepen en die gelinkt is aan een specifieke pointcut. Pointcut Een pointcut is een programma element van AspectJ die enkele regels omvat. Deze regels dienen eigenlijk om bepaalde gebeurtenissen te flaggen (herkennen). Pointcuts kunnen uitsluitend in een aspect klasse worden gebruikt. Annotations Via AspectJ is het ook mogelijk om zelf annotations, met eventueel meta-data te definiëren. Deze annotations kunnen daarna worden opgevangen in de aspect klasse. In hoofdstuk 4 wordt het gebruik hiervan gedemonstreerd. import import import import AnnotationEx.java java.lang.annotation.ElementType; java.lang.annotation.Retention; java.lang.annotation.RetentionPolicy; java.lang.annotation.Target; @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface AnnotationEx { String value(); //Toevoeging van extra meta-data } AspectClass.java import org.aspectj.lang.JoinPoint; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.*; @Aspect public class AspectClass { //cflow duid regels aan waarin de pointcuts al dan niet geldig mogen //zijn. Op die manier kunnen we oneindige loop voorkomen die zich //voordoen in de aspectklasse. @Pointcut("cflow(within(AspectClass))") public void codeWithinAspect() {} //Een pointcut dagt aanduid wat er vòòr de oproep van een methode //moet gebeuren. Analoog kan dit vervangen worden door After. //Hier wordt ook het gebruik van jokers aangetoond met behulp van *. @Before("call(void *.method1(..)) ") public void beforeMethod1() { Sytem.out.println(“Dit gebeurt voor de oproep van method1”); } //De around //gebeuren, //Een extra //annotatie pointcut duid aan wat er enerzijds vòòr de methode moet anderzijds erna. regel voor de pointcut is dat de aanwezigheid van de “AnnotationEx” er moet zijn. 16 @Around(("call(void *.method2(..)) && @annotation(AnnotationEx)") public void aroundMethod(ProceedingJoinPoint joinPoint){ Sytem.out.println(“Dit gebeurt voor de oproep van method2”); Jointpoint.proceed(); //De methode laten uitvoeren Sytem.out.println(“Dit gebeurt na het uitvoeren van method2”); } main.java public class main { public static void main(String[] args) { method1(5); method2(); } public void method1(int number){ System.out.println(number); } @AnnotationEx(“Hello”) public void method2(){ System.out.println(“Inside annotated method2”); } } Console output > > > > > Dit gebeurt voor de oproep van method1 5 Dit gebeurt voor de oproep van method2 Inside annotated method2 Dit gebeurt na het uitvoeren van method2 AspectJ is een heel krachtige tool waarmee we code at runtime kunnen opvangen. Annotations laten toe om op een eenvoudige manier stukken code te identificeren. Op die manier kunnen stukken code die mogelijkheid tot parallellisatie bieden meteen worden aangeduid. Dit idee werd gebaseerd op de pragma’s die ook worden gebruikt in OpenMP1. Aangezien Java dit niet ondersteund, zijn annotations voorlopig een goed alternatief. Ook is het mogelijk om stukken code niet meer uit te voeren: het gebruik van de around-pointcut, zonder toevoeging van de joinpoint.proceed() method, laat toe om code volledig te negeren. Op die manier wordt het mogelijk om een volledig nieuwe stuk code te injecteren en uit te voeren, ongeacht de oorspronkelijke code. 17 3.1.3.1 Project Lombok Het oorspronkelijk idee om een boilerplate model te gebruiken werd aangehaald tijdens de voorstudie bij het bekijken van Project Lombok [10]. Project Lombok is een runtime bibliotheek die toelaat om code automatisch te injecteren aan de hand van annotations. Momenteel zijn er officieel een 15-tal annotations gedefinieerd die meteen kunnen worden gebruikt. Een van die annotations (@Data) voegt automatisch de getters en setters toe aan een object, een andere annotatie (@NonNull) voegt automatisch een null-check statement aan een methode. De bibliotheek richt zich vooral tot het toevoegen van code die vaak voorkomt bij het construeren van een object. Er is echter een grote nuance verschil tussen AspectJ en Project Lombok: AspectJ is een tool waarmee een boilerplate model kan ontwikkeld worden. In principe zal AspectJ als een soort ontwikkelingstool dienen waarbij het gedrag van methodes kan worden aangepast, terwijl Project Lombok eerder code gaat toevoegen zonder verandering van het gedrag. 3.1.4 Java Reflections Java Reflections [11] laten toe om eigenschappen van code te inspecteren en deze te exploiten. Via een eenvoudig aanspreekpunt kunnen eigenschappen van een object zoals objecttype, klasse, super-klasse, methodes en bijhorende argumenten makkelijk worden opgevraagd. Deze thesis richt zich op het ontwerpen van een generieke tool voor parallellisatie. Genericiteit in die zin dat de tool zou moeten werken, ongeacht het gebruikte datatype. Via AspectJ kunnen methodes worden opgevangen. Met Java Reflections kunnen de argumenten van diezelfde methode worden geïnspecteerd en daarna geïnjecteerd worden in een eigen (parallelle) interpretatie van de code. import import import import AspectClass.java org.aspectj.lang.JoinPoint; org.aspectj.lang.ProceedingJoinPoint; org.aspectj.lang.annotation.*; java.lang.reflect.Method; @Aspect public class AspectClass { @Around("call(* *.*(..)) && @annotation(ReflectMe)") 18 public void racermethods(ProceedingJoinPoint joinPoint) { //Instance of class Object target = joinPoint.getTarget(); String methodname = joinPoint.getSignature().getName(); System.out.println("signature = " + methodname); joinpoint.proceed(); } } main.java public class main { public static void main(String[] args) { method(); } @ReflectMe public void dummymethodname(int number){} } Console output > Signature = main.dummymethodname() 3.2 Snelheidswinst behalen Het gebruik van meerdere cores heeft als doel een verbetering te doen in performance van een systeem. Op vlak van hardware is dit het gereduceerd vermogenverbruik. Op vlak van software is dit het behalen van snelheidswinst. Programmeer-technisch gezien kan dit door taken of data te splitsen en in te spelen op concurrency. Merk op dat concurrency op zich niet synoniem staat voor parallel. Twee methodes kunnen concurrent zijn, dit wil zeggen dat ze onafhankelijk van elkaar kunnen worden uitgevoerd op hetzelfde ogenblik. In de meeste gevallen bestaat er dan ook geen relatie tussen twee taken die concurrent zijn. Wanneer deze taken dan worden geïncapsuleerd in een thread dan laat die parallellisatie toe. De ene code is de andere code niet. De stijl van uitschrijven van code is eigen aan de programmeur. We kunnen dit vergelijken met een auteur en zijn schrijfstijl: bepaalde zinsconstructies en gebruik van specifieke, beeldende woorden kunnen een auteur 19 onderscheiden van andere auteurs. Afhankelijk van het doel van het verhaal is het de taak van de auteur om de boodschap zo goed mogelijk te brengen naar de lezer. Grote standaard constructies (inleiding, midden en slot op verschillende niveaus) en grammaticale regels zorgen voor deze eenheid. In programma-code heet dit een pattern: een veelvoorkomend, terugkerend probleem. Op vlak van parallelle constructies komen er ook dergelijke patterns voor. Hoe meer er wordt getraind op het herkennen van deze patterns, hoe vlugger concurrency kan worden herkend in bestaande programmacode. Het exploiteren van concurrency en ze laten runnen in aparte threads vormt onze basis voor het bereiken van snelheidswinst. In de volgende paragrafen worden enkele van deze patterns besproken. Deze patterns kunnen daarna worden geïmplementeerd in een skeleton. Wanneer erin geslaagd wordt parallelle skeletons automatisch te implementeren in programmacode, kan er worden gesproken over een automatisch parallellisatie tool. 3.2.1 Parallelle design patterns Het nut van design patterns is om op een generieke en abstracte manier vaak voorkomende programmeerproblemen op te lossen. Met andere woorden hergebruik van een design in plaats van code. Ook in parallel programmeren kunnen bepaalde patterns [12] worden uitgehaald. De keuze van pattern hangt af van hoe het algoritme of probleem zich gedraagt op vlak van data en tasks. Aangezien design patterns op zich een manier omschrijven om een probleem op te lossen, kunnen deze zeker worden gebruikt bij het uitwerken van een parallellisatie tool. Meer nog, deze patterns zullen voor een groot deel de performance bepalen van de te ontwerpen tool. Al deze patterns maken gebruik van het Fork-Join model [13]: Een hoofdthread vangt de te parallelliseren taken op en zal diezelfde taken splitsen over threads (forking). Wanneer de taken zijn uitgevoerd zullen die weer worden samengevoegd (reduce). Structuren kunnen ook genest zijn. Fig 3-2 : Fork-Join model, rood=hoofdthread, geel=splitsing over threads. 20 3.2.1.1 Task-gebaseerde patterns Een design pattern gebaseerd op task tracht een specifiek taak op te splitsen in deeltaken om die dan te verdelen over de beschikbare cores. Nadien worden de resultaten dan weer gecollecteerd (reduce). Dit staat welgekend als het fork-join model. Enkele voorbeelden worden hier besproken, gerangschikt volgens moeilijkheidsgraad: Absurdely parallel Een absurdely parallel pattern is een van de eenvoudigste patterns om te parallelliseren. Als voorbeeld wordt het optellen van twee matrices genomen: Programmeer technisch wordt deze bewerkingen uitgevoerd door gebruik te maken van een for-lus/for-each, hetzij geneste for-lussen. Op deze manier kan ieder overeenkomst element van beide matrices worden overlopen, opgeteld en gestockeerd worden in een oplossingsmatrix. Matrices worden meestal voorgesteld door multidimensionale arrays. Iedere iteratie is volledig onafhankelijk van elke andere iteratie, waardoor de for-lus perfect kan worden opgesplitst over verschillende threads. Hier wordt een enorm grote parallellisatiegraad bekomen. execution time Single thread Multi thread Core 1 Core 2 for each Core 3 Core 4 Fig 3-3 : Splitsing van absurdely parallel pattern Divide & Conquer Het divide & conquer pattern is een pattern dat berust op het recursief opsplitsen van taken in kleinere deeltaken. In die zin kunnen we de structuur analyseren door de taken voor te stellen als een boomstructuur waarin ieder niveau taken omvat die onafhankelijk van elkaar kunnen lopen. De uitdaging hierin bestaat om de boom op voorhand op te stellen om daarna een keuze te maken op welk 21 niveau de nodes en bijhorende child-nodes worden opgesplitst en verdeeld over verschillende cores. Single thread Multi thread Thread 1 Thread 2 Thread 3 Fig 3-4 : Divide en Conquer pattern Superscalar sequencing Superscalar sequencing is een pattern dat zich volledig berust op sequentiele uitvoering van taken waarbij men de splitsing gaat baseren op basis van analyse van data-onafhankelijkheid. Toekomstige taken die onafhankelijk zijn van de huidige taak kunnen meteen parallel worden uitgevoerd met de huidige taak. Hierbij moet er rekening worden gehouden met ongewilde bijwerkingen: bij het printen van karakters van een tekst is volgorde namelijk van groot belang. In die zin is het dus zinloos op een superscalar sequencing pattern te implementeren. execution time Sequentiele uitvoering onafhankelijkheids-barrier Multithreaded Core 1 Core 2 Core 3 Fig 3-5 : Superscalar sequencing 22 Actors Het actors pattern is een van de meest uitdagende patterns om te implementeren. Dit pattern wordt geïmplementeerd bij complexere taken, waarvan er niet meteen weet is van (on)afhankelijkheden. Een actor is op zich een parallel mathematisch model. Aan de hand van bijgehouden status en send- en receivemessages kan het een voorspelling maken en zo zelf kiezen welke actie er wordt uitgevoerd. Het grote voordeel hieraan is dat parallelle taken met gedeelde data veilig kunnen worden uitgevoerd met zekerheid van data consistentie. Dit model wordt reeds gebruikt in talen zoals Charm++ en parallelle libraries zoals MPI. Het grote nadeel is dat dit model vrij moeilijk is te automatiseren aangezien een dergelijk model enigszins berust op artificiële intelligentie. 3.2.1.2 Data-gebaseerde patterns In tegenstelling tot task-gebaseerde patterns kijk men bij data-gebaseerde patterns enkel naar de organisatie van data in het geheugen. Vanuit dit oogpunt wordt geanalyseerd hoe data mogelijks kan worden gesplitst en welke schrijf- en leesrechten er op een gegeven moment van toepassing kunnen zijn. De volgende beschrijvende patterns houden dus geen rekening met de calculaties. Subdivision Wanneer data moet worden gesplitst in kleinere deeltjes, kunnen we ons baseren op geometrische patronen. Enerzijds kan data worden opgedeeld in partities (subdeeltjes met eenzelfde grootte), segmenten (subdeeltjes met variërende grootte) en tegels (subdeeltjes met overlappende elementen). Verplaatsen of kopiëren van deze subdeeltjes kan zo parallel worden uitgevoerd om zo snelheidswinst te bekomen. Oorspronkelijke dataset Verdeling in partities Verdeling in segmenten Verdeling volgens tiling Fig 3-6 : subdivision in data 23 Search Search is een pattern met als doel grote datasets op te splitsen en te filteren. Data kan eerst volgens een subdivision pattern worden opgesplitst. Daarna kan er parallel worden gezocht over deze verschillende subdivisies. multi threaded Opzoeken in dataset for each Opzoeken in subdataset 1 Opzoeken in subdataset 2 Opzoeken in subdataset 3 Opzoeken in subdataset 4 Fig 3-7 : Search in data Shared Queue Een shared queue (gedeelde queue) functioneert identiek als een gewone queue. Het voordeel is dat data concurrent kan worden aangesproken, zonder dat de programmeur rekening moet houden met correcte semantiek zoals locks of semaphoren. In de bredere zin kunnen we dit aanzien als geographic data. Queue Thread 1 request ok Thread 2 request wait Thread 3 request ok Fig 3-8 : Shared queue Geographic data Geographic data kunnen we voorstellen als een generieke wrapper rondom een bepaald dataprimitieve of data-object. Aan de hand van extra metadata (status, busy, invalid, reachable) kunnen we zo de status reflecteren van de aangesproken data. Aan de hand van deze data kunnen we dan het gedrag van het programma aanpassen zodanig dat de programmeur niet meer hoeft in te zitten met data consistentie. 24 Datatype + Metadata (status) : valid, busy, invalid = GEOGRAPHIC DATA Fig 3-9 : Geographic Data 3.2.2 Skeletons Skeletons werden voor het eerst geïntroduceerd door Cole [14]. Cole omschreef dit als recurrent patterns of parallelism exploitation. Vertaald naar het Nederlands is dit: vaak terugkerende patterns bij parallellisatie. In dit concept wordt er een relatie gelegd tussen input en output bij een parallel ontwerp. Ook wordt er een relatie omschreven tussen functionele en parallelle semantiek. Concreet betekent dit dat de programmeur functioneel zou kunnen blijven programmeren. Met behulp van een gepast skeleton kan er een gemakkelijke implementatie en vertaling gebeuren naar parallelle code, zonder diepgaande kennis te hebben omtrent parallelle syntax. Aangezien specifieke parallelle problemen dus worden opgevangen door het skeleton betekent dit dat de programmeur meer vrijheid heeft om te concentreren op het functionele. Skeletons kunnen worden ontworpen aan de hand van software bibliotheken, objecten, programmeertaal-specifieke eigenschappen, … Parameters laten de programmeur toe te experimenteren om op die manier een zo hoog mogelijke graad aan performance te bekomen. 3.3 OpenMP De ontwikkeling van de parallellisatie tool, dat wordt besproken in paragraaf 3.4, is voor een groot deel gebaseerd op het OpenMP model [15,16]. Daarom verdient dit onderwerp ook de nodige aandacht in deze thesis. OpenMP is een interface die het toelaat om gemakkelijk multi-core applicaties te ontwikkelen. Momenteel is deze interface enkel beschikbaar voor de talen C/C#/C++ en Fortran. OpenMP is al jaren in ontwikkeling en heeft inmiddels een sterk, groeiende community. Wat OpenMP zo populair maakt is de gebruiksvriendelijkheid qua notatie in 25 de programmeercode: De basis van het model is door gebruik te maken van “#pragma’s”. Via deze compiler directives kunnen stukken code aangeduid worden, die parallel moet worden uitgevoerd. Wanneer deze pragma’s niet herkend worden door de compiler dan worden die gewoon genegeerd. In die zin is het een elegante oplossing om eenzelfde broncode te gebruiken voor zowel sequentiële uitvoering als parallelle uitvoering. 3.3.1 Gemakkelijk en intuïtief In dit voorbeeld geeft de expressie #pragma omp parallel for aan dat de inhoud van deze programmalus in principe parallel uitgevoerd kan worden. #pragma omp parallel for for(int i = 0;i < 100000;i++){ array[i] = array[i] * array[i]; } Het volgende voorbeeld toont aan hoe eenvoudig het is om OpenMP te laten weten dat de bewerking een reductie naar sommatie is : double sumvariable = 0.0; int i; #pragma omp parallel for reduction (+:sumvariable) for(i = 0;i < 100000;i++){ sumvariable = array[i]; } 3.3.2 Robuust en krachtig Bij het uitvoeren van een fork-join (fig. 3-2) zal er impliciet een barrier worden geplaatst in het join-proces. De programmeur kan dit gedrag enigszins expliciet zelf aanduiden, met oog voor leesbaarheid, anderzijds kan hij deze barrier verwijderen door het toevoegen van no-wait. 26 #pragma omp parallel for no wait for(int i = 0;i < 100000;i++){ array[i] = array[i] * array[i]; } De mogelijkheid om het geheugen in te delen (stack en heap) gebeurt ook met eenvoudige constructies: Stel variabelen: A=1, B=1, C=1 OpenMP construct: #pragma parallel private(B) firstprivate(C) In parallele omgeving: B A B C Na uitvoering: B en C krijgen hun initiële waarde 1 terug A veranderd naargelang de verwerking in thread en is is is C zijn lokale variabelen in de thread een gedeelde globale variabele undefined 1 Dit zijn slechts een aantal voorbeelden die enerzijds aantonen hoe robuust en veilig het model wel is voor programmeurs en anderzijds ook de kracht aantonen van het model waarmee een paar kernwoorden het parallel gedrag grondig kunnen veranderen. 3.4 Ontwerp van automatische parallellisatie tool De technologie en ontwikkeling van automatische parallellisatie tools zijn niet nieuw. Tegenwoordig bestaan er al heel wat op de markt. Sommige tools blijken echter te verdwijnen (JavaOMP [17]) of blijven stilstaan in hun theoretisch concept (Parall4All), anderen daarentegen worden stilaan een gevestigde standaard (OpenMP). In deze paragraaf worden eerst de verschillende stappen overlopen om een dergelijke tool te ontwerpen, daarna geven we een overzicht van bestaande tools met hun voor- en nadelen waaruit er dan een eigen implementatie kan worden voorgesteld. 27 3.4.1 Stages tot automatische parallellisatie Automatisatie en uitvoering van parallelle code kan worden opgesplitst in een opeenvolging van verschillende stages. Tools die automatische parallellisatie toelaten zijn dan ook meestal een clustering van (al dan niet) eigen ontwikkelde sub-tools die elk een bepaalde functie zullen vervullen van het proces. Er worden drie stages overlopen: als eerste de scan- en analysestage, daarna de shedulingstage en tot slot de injectie- en uitvoeringsstage van parallelle code. 3.4.1.1 Scan en analyse Dit is de eerste stage waarin de scanner de programmacode inleest en iedere statische of externe verwijzing zal identificeren. Op elke lijn van de programmacode wordt gecontroleerd of er eventueel patterns in voorkomen die men kan identificeren om daarna te stockeren in tokens. De analyzer zal daarna de tokens onderzoeken om concurrency te exploiten: functies die totaal onafhankelijk kunnen worden uitgevoerd, worden aangeduid als individuele tasks. Daarna wordt op zoek gegaan naar combinaties van functies waartussen er een geringe afhankelijkheid is, deze kunnen dan op zich opnieuw worden aangeduid als individuele tasks. Gevoelsmatig kan er worden aangevoeld dat deze stage een heel moeilijk proces is. Er is een grondige kennis nodig van de code: de tool moet kunnen “aanvoelen” wat de programmeur wil bereiken. In die zin behoort het ontwerp van zo’n systeem tot de discipline van de artificiële intelligentie. 3.4.1.2 Sheduling Een sheduler wordt vertaald als een takenplanner. Aan de hand van data-dependencies wordt de volgorde van uitvoering van taken bepaald volgens prioriteit of volgens starten eindtijd. De sheduler zal de taken zo optimaal proberen te organiseren in een aantal threads. Er bestaan twee organisatievormen: Ofwel worden taken gepland in functie van het aantal beschikbare cores. Indien de CPU hyper-threading ondersteunt, worden er in principe virtuele cores ter beschikking gesteld. De threads worden daarna gemultiplexed. Bij een tweede organisatievorm worden taken gepland in functie van een tijdsgebonden criterium waarin uitvoering zo dicht mogelijk in een bepaald tijdsinterval moet gebeuren. 3.4.1.3 Injecteren en uitvoering van code In deze laatste stage worden bepaalde stukken code herschreven of vervangen door parallelle constructies. Iedere mogelijke sequentiele code wordt vervangen door een 28 parallel pattern die toelaat om ofwel de programmacode uit te voeren op verschillende cores ofwel de executie van die programmacode binnenin een vast tijdsinterval te laten verlopen. 3.4.2 Bestaande tools Tool [18] Stage* + - Score Java-omp sh inj - Object oriented model - In theoretische fase 3 Lombok inj - Object oriented model - Annotation based - Behoud van oorspronkelijke code - Closed source - Geen parallelle constructies 4 OpenMP Sh inj - Gebruik van pragma’s - Easy code - Data consistentie - Grote, levende community - Niet object oriented Sc sh inj - Gebruik van OpenMP - Vol-automatisch - Niet object oriented - Laatste update in 2012 - Enkel eenvoudige patterns 2 sh inj - Gebruik van OpenMP - Behoud van oorspronkelijke code - Independency in sequentiele code - Niet object oriented - Commercieel 2 sc sh inj - Gebruik van OpenMP - Recentelijke update - Specialisatie: loop-nesting - Niet object oriented - Beta-fase - Moeilijk code, veel package dependencies 1 sc sh inj - Sterk mathematisch model (Omega Calculator library) - Puur functioneel - Wetenschappelijke doeleinden 1 Par4all Parallware sc Pluto Traco 5 Fig 3-10 : Vergelijking bestaande parallelle tools *sc = scan & analyse, sh = sheduling, inj = injectie van code Uit vergelijking van bovenstaande tools werden de hoogste scores gegeven aan het OpenMP model. OpenMP is een interface voor het programmeren van toepassingen (API) die het programmeren van multi-core applicatie aanzienlijk vereenvoudigt. MP 29 staat voor Multi Processing, O staat voor Open wat wil zeggen dat de implementatie door iedereen kan worden gebruikt en aangepast zonder dat er daar voor betaald moet worden. De tweede hoogste score werd gegeven aan Project Lombok. Deze tool is ontstaan vanuit de noodzaak aan een boilerplate model in de Java-omgeving. We nemen als voorbeeld de aanmaak van objecten: Vaak gaat dit gepaard met de aanmaak van enkele getters() en setters(). Met het toevoegen van annotatie @Data kan er at-develop(!) time meteen code worden geïnjecteerd. Dit is namelijk mogelijk aangezien de plugin Lombok geïntegreerd wordt in de IDE. 30 4 Java implementatie 4.1 Procedureel naar object oriented model Vanuit de vergelijking tussen verschillende parallellisatie tools (paragraaf 3.3.2) wordt besloten om een model te maken baserend op het bestaand OpenMP model. Het OpenMP model bestaat momenteel nog niet voor Java. De hoofdreden hiervoor is vooral de manier waarop Java code wordt gecompileerd en gerund op een virtuele machine (JVM). 4.1.1 Waarom porting niet eenvoudig is Aangezien het OpenMP model een robuust model is waarbij er al jaren onderzoek in is kan de vraag kan gesteld worden waarom het OpenMP model niet kan worden geport naar een object oriented omgeving? De verklaring hiervoor kan worden gegeven wanneer er wordt naar de onderliggend laag van de object oriented taal Java. In deze thesis worden deze uitdagingen aangekaart als illusies in sheduling, thread implementatie en data. [17] Sheduling illusie Executie van code wordt automatisch door de JVM gemultiplexed over een verschillend aantal threads, dit in functie van behoud van performance. Afhankelijk van versie en instellingen wordt er zelf een bepaald policy gebruikt. De programmeur heeft hier nauwelijks controle op. Thread illusie Java biedt uitgebreide ondersteuning voor parallel programmeren op twee manieren: de concurrency bibliotheken en de threading bibliotheken. Het uitvoeren van code in een aangemaakte thread is eigenlijk een virtuele thread. De 31 sheduling, besproken hierboven, zal namelijk diezelfde thread verdelen over threads die de JVM zelf heeft aangekregen van het OS. Een specifieke core reserveren is in die zin ook niet mogelijk in Java. Data illusie Ieder object wordt voorzien van een geheugenlocatie. Deze geheugenlocatie is echter ook virtueel. Het adres van de fysieke locatie in het geheugen kan niet worden verkregen. Daarbij is het geheugenmodel zodanig opgebouwd dat we (praktisch uitsluitend) werken met referenties naar een object. Het clonen van objecten in Java wordt niet aangeraden, en is ook niet altijd mogelijk. Pragma’s Pragma’s worden niet ondersteund in Java. Uiteindelijk komt het erop neer dat het vrij open model van OpenMP te weinig low level access zou krijgen waardoor een groot stuk van de interface zou moeten herschreven moeten worden. Daarbij zou ook de kracht van het OpenMP model verdwijnen. 4.1.2 Realisatie van een model Studie van het OpenMP model laat ons toe om een soortgelijke variant te maken in Java. Sterke concepten uit het OpenMP werden overgenomen om een implementatie te realiseren: OpenMP Pragma’s Vertaling naar Java “#Pragma comment” geeft de compiler extra informatie over een stuk code dat daarop volgt. Via een linker kan deze commentaar worden omgezet naar een bepaald gedrag. In het OpenMP model werden deze pragma’s gebruikt om parallelle codes aan te duiden. Pragma’s worden echter niet ondersteund in Java. Een goed alternatief is daarom het gebruik van @annotations. Deze annotations worden gedefinieerd door Aspects aan te maken. Pattern bibliotheek OpenMP omvat een uitgebreid bibliotheek aan parallelle patterns, opgebouwd over de jaren heen. In deze thesis werden enkele 32 parallelle patterns ontworpen met aandacht voor genericiteit. Deze patterns worden ingesloten in een advice1 en kunnen worden aangesproken met behulp van AspectJ Annotations. Data consistentie Het OpenMP model is standaard in zekere mate op vlak van data consistantie robuust opgebouwd: Locks, barriers en mutexen worden impliciet geplaatst. De programmeur kan echter wel expliciet deze semaforen toevoegen (een reden zou bijvoorbeeld ook leesbaarheid van code zijn). Het datamodel van Java is op zich ook robuust opgebouwd maar is weinig controleerbaar. In die zin werden de patterns en threads in deze thesis op een manier geschreven, waarop data consistentie weinig tot geen zorg mogen zijn. Behoud originele code Het genereren van nieuwe code gebeurt at compile time. De programmeur hoeft, buiten het plaatsen van annotations, in principe niets aanpassen aan de code. In onderstaand schema wordt een samenvatting van de ontworpen tool weergegeven. Dit model vormt de fundament van de gerealiseerde prototypes die worden besproken in volgende paragrafen. @ParallelKeyword public * method(args...) {...} AspectJ compiler geännotteerde code Concurrente en Replace code parallelle code bibliotheek Java compiler Parallelle uitvoering van de methode Fig 4-1 : Schema parallellisatie tool in Java 1 Een advice is het stuk code dat wordt uitgevoerd bij oproep van een bepaalde joinpoint in AspectJ 33 4.2 Opbouw van prototypes De bouw van een pattern begint vanuit een veelvoorkomend, specifiek probleem. In het geval van parallellisatie is de uitdaging vooral op welke manier datatypes of taken kunnen worden gesplitst op een zo generiek mogelijke manier. Daarna is het een kwestie van deze subdeeltjes op een logische manier te incapsuleren in threads, die dan worden verdeeld over beschikbare cores. De realisatie van een complete tool wordt stap voor stap opgebouwd aan de hand van prototypes. In een eerste prototype (absurdely for) wordt de focus gelegd op het injecteren en vervangen van code. Het exploiteren van AspectJ, meer bepaald joinpoints en advices, zal hierin een grote rol spelen. In een tweede prototype (SortRacer) wordt bestudeerd hoe methodes kunnen worden opgevangen om daarna opnieuw te worden geïnjecteerd met aangepaste argumenten. In het derde en laatste prototype (Rups) worden de mogelijkheden van een threadpool geëxploiteerd: iedere aangeroepen methode wordt in een thread uitgevoerd. Wanneer er geen threads beschikbaar zijn, worden ze in een queue geplaatst. 4.2.1 Absurdely for Een van de meest eenvoudigste patterns is de absurdely for pattern zoals besproken is in paragraaf 3.2.1.2. Programmeer-technisch neemt het datatype vaak de vorm aan van een, al dan niet multidimensionale, array of list. Wanneer berekeningen op de data wordt gedaan, vertaald zich dit in de meeste gevallen in iteraties doorheen een for-lus. Om dergelijke calculaties parallel uit te voeren kunnen de array-indices worden gebruikt als splitsingsindicator voor de for-lus. Dit prototype demonstreert de parallelle executie van een bewerking op een 2D-matrix. Oorspronkelijke dataset Verdeling in partities Verdeling in segmenten Verdeling volgens tiling Fig 4-2 : Data gebaseerde pattern (subdivision in data) 34 4.2.1.1 Java code Hieronder wordt de oorspronkelijke, sequentiele code gegeven. Om deze methode op te vangen door AspectJ duiden we deze aan via een annotatie (@Parallelfor). absurdelyFor_main.java … @Parallelfor //Aspect annotation public void matrix(int[][] matrixa, int[][] matrixb, int size){ matrixresult = new int[size][size]; for (int i = 0; i<size;i++){ for (int j = 0; j<size;j++){ for (int times = 0; times<1000 ; times++) { matrixresult[i][j] += matrixa[i][j] * matrixb[i][j]; } } } } … Een annotatie aanmaken en definiëren gebeurt op volgende wijze (merk op: extensie type is hier .aj) Parallelfor.aj import … @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface Parallelfor { String value(); //Eventueel kan er hier paramaters aan worden //toegevoegd. } Hieronder wordt aangetoond hoe de methode nu wordt opgevangen en welke code er wordt geïnjecteerd. Dit is ook de code die achter de schermen wordt uitgevoerd. De programmeur ziet dit op het eerste zicht niet. Aangezien de focus hier wordt gelegd op de injectie via AspectJ werd de code nog niet geoptimaliseerd voor generieke uitvoering. aspectlibrary.java … //Alle methodes die worden opgeroepen waarbij Parallelfor werd geannoteerd @Around("call(* *.*(..)) && @annotation(Parallelfor)") 35 //Dit is het bijhorend advice public void ninjamethods(ProceedingJoinPoint joinPoint){ Object[] arguments = joinPoint.getArgs(); int [][] matrixaa = (int[][])arguments[0]; int [][] matrixbb = (int[][])arguments[1]; matrixJob(matrixaa, matrixbb, size); } public void matrixJob(int matrixA[][],int matrixB[][],int dimension){ //Het aantal threads gelijkstellen aan het aantal beschikbare CPU’s final int nThreads = Runtime.getRuntime().availableProcessors(); final int matrixC[][] = new int[dimension][dimension]; //Een blocksize definieren om de for-lus op te splitsen final int blockSize = dimension/nThreads; //Thread pool aanmaken Thread[] threads = new Thread[nThreads]; for(int n=0; n<nThreads; n++) { final int finalN = n; threads[n] = new Thread() {@Override public void run() { final int beginIndex = finalN*blockSize; int endIndex = beginIndex + blockSize; for(int i=beginIndex; i<endIndex; i++) { for(int k=0; k<dimension; k++) { for (int times = 0; times<1000 ; times++) { matrixC[i][k] += matrixA[i][k] * matrixB[i][k]; } }}}}; threads[n].start(); } for(int n=0; n<nThreads; n++){ threads[n].join(); } } … 36 4.2.1.2 Resultaten en bespreking Execuce!cjd![ms]! Prototype : Parallelfor speedup 160! 140! 120! 100! 80! 60! 1! 2! 4! 8! Aantal!threads! Fig 4-3 : Prototype 1, Parallelfor speedup grafiek2 De matrixbewerking werd telkens 100 maal uitgevoerd. Bij het uitvoeren op 1 thread werd een executietijd van ongeveer 145ms bekomen. De parallelle executie met behulp van AspectJ laat een winst in executietijd zien van ongeveer 1,5 bij 2 threads, bij 4 threads werd de ongeveer 2 maal korter. De reden waarom er geen perfecte verdeling is in snelheidswinst is omdat de CPU hyper-threading ondersteunt (in principe zijn er maar 2 fysieke cores). In die zin werden plausibele en verwachtte resultaten bekomen. Bij het verdelen over 8 verschillende threads werd er nagenoeg geen snelheidswinst meer bekomen en stagneerde het resultaat. 4.2.1.3 Andere mogelijke gebruiken Zoekopdracht Het zoeken van data in een lijst of een array gebeurd vaak door middel van iteraties. Wanneer de lengte van de lijst bekend is, wordt het zo mogelijk om de iteraties te splitsen over een aantal threads. Bij deze uitwerking worden alle andere threads stopgezet wanneer het resultaat is gevonden. Refreshrate De performance van applicaties die op regelmatige tijdstippen worden ge-refreshed hangen af van hoe snel de berekeningen tussenin worden uitgevoerd. Bij visuele games zou dit bijvoorbeeld de berekening kunnen zijn van hun nieuwe positie in het spel. De updates van deze positie-waarden van alle game-objecten kunnen worden gesplitst over meerdere threads, waardoor de framerate aanzienlijk kan worden verhoogd. 2 Zie bijlage 2 voor de gedetailleerde datapunten 37 4.2.2 SortRacer In dit prototype wordt bestudeerd hoe methodes kunnen worden opgevangen om daarna opnieuw te worden geïnjecteerd met eventueel gemodificeerde argumenten. Als toepassing wordt de racer geïmplementeerd: het doel van een racer is om zo snel mogelijk een resultaat op te vangen van een methode. Dit gebeurt door eenzelfde methode simultaan te laten lopen op verschillende threads. Wanneer de snelste thread een resultaat levert dan worden de andere threads stopgezet. Het volgende voorbeeld (SortRacer) is in principe een variant van de racer. Het doel is om een dataset zo snel mogelijk te sorteren en dit kunnen we bekomen door simultaan meerdere sorteeralgoritmes te laten lopen op de cores. Aangezien de datasets gewijzigd worden, is het hier noodzakelijk om per thread een nieuwe kopie aan te maken van diezelfde dataset. Er wordt een vergelijking gemaakt tussen het uitvoeren van single-threaded sorteermethodes en de parallelle executie waarbij meerdere sorteermethodes simultaan worden uitgevoerd. 4.2.2.1 Java code RacerSort_main.java @RacerSort public void sortthis(int[] arrayInput) { //Eender welk sorteer algortime } aspectlibrary.java @Around("call(* *.*(..)) && @annotation(RacerSort)) public void racersort(ProceedingJoinPoint joinPoint) { //Opvangen van de methode final String methodname = joinPoint.getSignature().getName(); final Method method = target.getClass().getMethod(methodname, null); final Object target = joinPoint.getTarget(); //Opvangen van de argumenten van de methode Object[] arguments = joinPoint.getArgs(); final int [] arrayToSort = (int[])arguments[0]; //Aanmaak van een threadpool waarin methodes woden uitgevoerd ExecutorService executorService = Executors.newFixedThreadPool(4); Set<Callable<String>> callables = new HashSet<Callable<String>>(); //Hier worden verschillende sorteer algoritmes toegevoegd aan de pool // waaronder ook de originele methode (invoke.method) callables.add(new Callable<String>() { …method.invoke(target);…}); callables.add(new Callable<String>() { …SORTING ALGORTIME 2…}); callables.add(new Callable<String>() { …SORTING ALGORTIME 3…}); 38 callables.add(new Callable<String>() { …SORTING ALGORTIME X…}); //invokeAny: Hier gebeurt bevindt zich de kracht van een racer. //De eerste thread die een result geeft, is de winnaar. String result = executorService.invokeAny(callables); executorService.shutdown(); } 4.2.2.2 Resultaten en bespreking In deze test werden verschillende datasets, met telkens een kwadratisch oplopend aantal elementen, gesorteerd aan de hand van een verschillende single-threaded sorteeralgoritmes (merge-, selection-, insertion- en bubblesort). Daarvan werd telkens de executietijd opgenomen. Om het prototype te testen werd dezelfde test uitgevoerd via de SortRacer methode: simultaan uitvoeren van verschillende sorteeralgoritmes op elk een aparte thread. De bekomen resultaten worden hieronder weergegeven, onder logaritmische schaalverdeling: executie tijd [ms] Executie tijd i.f.v. het aantal te sorteren elementen 65536 32768 16384 8192 4096 2048 1024 512 256 128 64 32 16 8 4 2 1 # Elements 256 512 1024 2048 RACER 4096 MERGE 8192 16384 SELECTION 32768 65536 BUBBEL 131072 INSERT Fig 4-4 : Prototype 2, RacerSort speedup grafiek3 3 Zie bijlage 3 voor de gedetailleerde datapunten 39 De blauwe lijn toont de executie-tijd aan van de RacerSort. De bekomen resultaten zijn heel plausibel: het sorteren van data met behulp van de racer geeft ons telkens ongeveer het resultaat weer die bekomen zou worden bij gebruik van zijn optimale sorteeralgoritme. Er is echter ook te zien dat de minimale tijd nooit exact wordt behaald: het aanmaken van threads zorgt namelijk voor een zekere overhead. Een nadeel van deze methode is dat iedere thread een kopie nodig heeft van de dataset. Bij extreem, grote datasets kan dit dus een enorme bottleneck vormen. Indien merge-sort wordt vergeleken met de insertion-sort, dan kan er besloten worden dat insertion-sort beter scoort bij een dataset met weinig elementen en dat merge-sort beter scoort bij een dataset met veel elementen. In die zin is dit prototype praktisch relevant wanneer de programmeur dataset wil sorteren, waarbij de grootorde van diezelfde dataset niet op voorhand bekend is. Focus op de grootte van een dataset 2048 1024 executie-tijd [ms] 512 256 128 64 32 16 8 4 # Elements 256 512 1024 2048 4096 RACER! 8192 16384 MERGE! 32768 INSERT! Fig 4-5 : Prototype 2, RacerSort speedup grafiek, focus op datasetgrootte4 4 Zie bijlage 3 voor de gedetailleerde resultaten 40 4.2.2.3 Andere mogelijke gebruiken Er kan worden opgemerkt dat deze prototype op zich veel resources inneemt en op die manier niet zo “milieuvriendelijk” is. Het opzet van deze prototype is echter om op een zo snel mogelijke manier een resultaat te bekomen. Het idee hierachter is dat de oplossing op een bepaald probleem vaak op meerdere manieren kan worden bepaald. Tree search Het opzoeken van data in een boomstructuur kan op verschillende manier: depth first, breadth first, Monte Carlo, random search,… Deze methodes kunnen simultaan worden uitgevoerd waardoor het resultaat sneller kan worden gegeven. 4.2.3 Rups De derde en laatste prototype berust op het principe van superscalar sequencing (paragraaf 3.2.1.1). Taken, die in dit geval geïncapsuleerd zijn in methodes en onafhankelijk van elkaar zijn, worden telkens gepland en daarna uitgevoerd in een aparte thread. Op die manier kan er optimaal gebruik gemaakt worden van de beschikbare cores. Het gebruik van aspects is hier echter wat trivialer: om het effect te bekomen moet de programmeur een methode aanmaken waarin de uit te voeren, onafhankelijke submethodes zijn gebundeld. Dit is belangrijk aangezien de aanmaak en het uitvoeren van taken in een threadpool altijd gepaard moet zijn met een executor.shutdown() (sluiten van de threadpool). De shutdown is noodzakelijk om threads weer vrij te maken aan het systeem. De code wordt als volgt beschreven: 4.2.3.1 Java code Rups_main.java … @Rups //De hoofdmethode waarin alle submethodes worden opgeroepen public void rups() { method1(); method2(); method3(); method4(); } @NoDependency //Een methode die onafhankelijk kan worden uitgevoerd public void method1…4() { } … 41 aspectlibrary.java //Via een controlflow wordt recursiviteit in aspects uitgeschakeld. //Hiermee wordt er voorkomen dat er oneindige lussen worden gevormd (en //dus ook oneindig aantal threads worden aangemaakt). //Dit kan gebeuren wanneer een annotated methode, een andere annotated //methode zou oproepen. @Pointcut("cflow(within(Logger))") public void codeWithinAspect() {} //Vooraleer de methode wordt uitgevoerd, wordt een threadpool geopend. @Before(" … @annotation(Rups) && !codeWithinAspect()") public void racersort(ProceedingJoinPoint joinPoint) { executorService = Executors.newFixedThreadPool(4); Set<Callable<String>> callables = new HashSet<Callable<String>>(); } //Iedere aangeroepen submethode wordt toegevoegd aan de queue van de //treadpool. @Around(" … @annotation(NoDependency) && !codeWithinAspect()") public void racersort(ProceedingJoinPoint joinPoint) { Opvangen de methode Methode toevoegen aan de threadpool } //De shutdown zorgt ervoor dat de methodes worden uitgevoerd om daarna de //threadpool af te sluiten. @After(" … @annotation(Rups) && !codeWithinAspect()") public void racersort(ProceedingJoinPoint joinPoint) { executorService.shutdown(); } 4.2.3.2 Resultaten en bespreking In dit geval is de snelheidswinst volledig afhankelijk van de opgeroepen sub-methodes. Via dit prototype wordt er op een relatief eenvoudige manier gebruik gemaakt van de beschikbare vrije threads. Het verschil met superscalar sequencing is hier dat de programmeur zelf moet aanduiden welke methodes er onafhankelijk kunnen worden uitgevoerd van elkaar (in plaats van een intelligent systeem dat dit automatisch kan detecteren). Via parameters is het ook mogelijk om een bepaalde prioriteit mee te geven aan de methodes. In die zin kan de programmeur meer controle uitoefenen op het proces. 42 Rups_main_prior //Meegeven van een integer dat de prior aanduid van een bepaalde methode. //Bij exectutie in een threadpool krijgen deze dan voorrang zodra er een //thread vrijkomt. @NoDependency(prior = 2) public void method1() { } @NoDependency(prior = 1) public void method2() { } 4.2.3.3 Andere mogelijke gebruiken In principe gaat de programmeur via deze methode zelf een round-robin sheduler5 gaan aanmaken op basis van tasks. Om het ongewenste effect van overhead niet te krijgen, zullen de taken groot genoeg moeten zijn. Het voordeel is dan natuurlijk dat zo’n taak wordt uitgevoerd in een vrije thread. Het grote voordeel is dat de gebruiker een zekere garantie heeft dat alle beschikbare cores worden gebruikt en dat zo’n taak wordt uitgevoerd in een vrije thread. Het gebruik van priorities laat toe om de volgorde, waarin taken worden uitgevoerd, dynamisch te wijzigen. 4.3 Case 1 : TESS-modules Als eerste toepassing van de gemaakte prototypes bespreken we de TESS-modules. Dit framework heeft als doel economische veranderingen in functie van de tijd weer te geven. De kleinste eenheid is een timefunction (object) waarvan de parameters louter primitieven zijn, opgeslagen in een array. Die elementaire timefunctions kunnen op zich zelf worden ingevoerd als parameter in een andere timefunction (som, verschil, product,…) dat op zich ook kan worden ingevoerd als parameter van een nog grotere orde timefunction. De relaties tussen die timefunctions kunnen visueel worden voorgesteld aan de hand van volgende gelaagde boomstructuur : De boom bevat verschillende niveaus. Parallellisme kan worden bekomen door gebruik te maken van het principe van divide en conquer (paragraaf 3.2.1.1) en op de gepaste plaatsen in de code een barrier te plaatsen (fig. 4-6) 5 What is Round Robin? http://whatis.techtarget.com/definition/round-robin 43 Timefunctions Timefunctions bestaande uit andere timefunctions Atomic timefunctions Een mogelijke splitsing, startende vanuit niveau 1 Fig 4-6 : boomstructuur van timefunctions In deze case wordt een methode besproken waarmee een dergelijke boomstructuur kan worden aangemaakt en geanalyseerd. 4.3.1.1 Java code De aanmaak van eender welk type timefunction wordt opgevangen door een aspect. De reden waarom getID() wordt opgeroepen is omdat aspect annotations op constructors niet worden toegelaten. AbstractTimeFunction.java public abstract class AbstractTimeFunction extends AbstractTimeFunction { ... //Default constructor public AbstractDualTimeFunction() { this.setName("AbstractDualTimeFunction"); this.dualTerms = new ArrayList<TimeFunction>(); getID(); collectedresult = null; } @GetID //Annotated aspect public void getID(){} ... } 44 De logica die nodig is om de boom op te bouwen worden volledig in de aspect klasse uitgevoerd: Als eerste wordt gecontroleerd of de timefunction een primitieve timefunction is. Hiermee wordt bedoeld of de timefunction in zekere zin atomair is, en dus niet is opgesteld uit andere timefunctions. Wanneer dit het geval is, wordt de hashcode van dit object als key gebruikt in een hashmap met alsbijhorende waarde 1 (laagste niveau in een boom). Indien deze geen primitieve timefunction is, dan wordt opgezocht welke andere timefunction(s) er werden ingegeven als parameter (parametertimefunction). Aangezien deze parametertimefunction reeds aangemaakt is op een vroeger tijdstip, volstaat het om deze op te zoeken in de hashmap en de bijhorende key parameterfuntion(key) op te vragen. De key van de oorsponkelijke timefunction is dan simpel weg parameterfuntion(key)+1 (met andere woorden, een niveau erboven). aspectlibrary.java @Aspect public class Parallelmethods { HashMap<Object, Integer> tree = new HashMap<Object, Integer>(); int maxprior = 0; //ID annotation opvangen en de tree opbouwen. @Around("call(* *.*(..)) && @annotation(GetID) && !withinAIDT()") public Object getID(ProceedingJoinPoint joinPoint) { Object result = joinPoint.proceed(); STAP 1 : indien primitieve timefunction, dan toevoegen aan hashmap met niveau (value) 1. STAP 2 : indien niet, dan toevoegen aan hashmap met niveau = (niveau van onderliggende timefunction + 1). joinPoint.proceed(); } } 4.3.1.2 Resultaten en bespreking De test werd uitgevoerd op volgende relaties tussen timefunctions : //Dit zijn primitieve timefunctions TimeFunction costs = new GivenFunction(5,3,5,3,2); TimeFunction revenues = new GivenFunction(1,1,1,1,1}); TimeFunction revenues2 = new GivenFunction(4,6,2,6,3}); //Dit zijn geen primitieve timefunctions, en hebben als parameter //andere timefunction TimeFunction yearlyOutcome = new Subtraction(revenues, costs); een 45 TimeFunction yearlyOutcome2 = new Addition(revenues2, costs); TimeFunction total = new Addition(yearlyOutcome, costs); De output van een dergelijke tree ziet er als volgt uit : ID(1225358173)=3 ID(7692872336)=2 ID(1342443276)=1 ID(932172204)=2 ID(512282289)=1 ID(214126413)=1 Het resultaat is een simplistische versie van een boom waaruit relaties tussen de verschillende timefunctions kunnen worden afgelezen. Op zich zou het nu een logische stap zijn om bepaalde takken (bevindend op eenzelfde niveau) parallel te laten uitvoeren op aparte threads. Jammer genoeg was het niet mogelijk om dit te bekomen aangezien het calculeren van de resultaten praktisch volledig recursief gebeurt: de calculatie start aan de top van de boom en baant zich zo een weg doorheen de verschillende niveaus. Het probleem vormt zich doordat de code zodanig is geschreven dat er van elke stap een return waarde wordt verwacht alvorens te kunnen doorgaan naar een volgende berekening (data wordt niet expliciet opgeslagen). In een parallelle omgeving zou zich dit vertalen in het continu plaatsen van barriers. Dit wil niet zeggen dat parallelle executie onmogelijk is. Een oplossing zou bijvoorbeeld zijn door recursie pas te starten vanaf een bepaald niveau. Een andere mogelijkheid is, om telkens de bekomen resultaten op te slaan onder een variabele in het object. Aangezien het grondig herschrijven van code niet behoort tot het opzet van deze thesis, wordt er niet verder op ingegaan. Er wordt besloten dat de code een lage mogelijkheid heeft tot parallellisatie volgens de principes ontwikkeld in deze thesis. 4.4 Case 2 : Tower Defense Tower Defense is een genre van strategische games. In deze game is het doel van de speler te voorkomen dat een aantal vijanden (creatures) een bepaald punt bereikt. Om dit doel te bereiken, bouwt de spelers torens (towers) die schade aan de monsters kunnen toebrengen. Bij sommige spelen in het genre ligt de route van de monsters vast, en moet de speler zijn torens zo plaatsen dat die de meeste schade doen, bij andere kan de plaatsing van de torens de route van de monsters veranderen. 46 Programmeertechnisch gezien overloopt de code telkens een bepaalde volgorde van fases: In de eerste fase move bewegen alle creatures een stap dichter richting hun doel. De tweede fase attack wordt gecontroleerd of een creature/tower al dan niet in het schietbereik ligt van een tower/creature. Indien ja dan zullen er damage-punten worden gegeven aan het aangevallen object. De laatste fase controleert of een creature of tower nog genoeg health-punten heeft. Indien niet, dan wordt dit object verwijderd. Model.java … public void executeGameStep(){ runMoveUpdate(); runAttackPhase(towers, creatures); runAttackPhase(creatures, towers); removeDeadObjects(towers); removeDeadObjects(creatures); } … //Fase //Fase //Fase //Fase //Fase 1 2 2 3 3 Een fase wordt als volgt geprogrammeerd: Model.java … @ParallelFor //Annotation private void runAttackPhase( List<? extends Attacker> attackers, List<? extends Attackable> attackables){ for (Attacker attacker : attackers) { Attackable attacked = findInRange(attacker, attackables); if(attacked != null){ … //damage-punten toekennen } // Code om de berekening aanzienlijk zwaarder te maken double p = System.currentTimeMillis(); for (int a = 0; a <100; a++){ p=p+p; for (int b = 0; b <100; b++) { p = p + p; for (int c = 0; c <100; c++) { p = p + p; } p = System.currentTimeMillis(); } p = System.currentTimeMillis(); } } } … Iedere fase itereert telkens over alle towers of creatures. Aangezien een iteratie volledig onafhankelijk is, kan er hier worden ingespeeld op automatische parallellisatie. 47 4.4.1.1 Generieke, parallelle Java code Dit programma verleent zich perfect om generieke code te schrijven: een methode (fase) krijgt als eerste argument de lijst van creatures/towers waarover moet worden geïtereerd. In die zin kunnen we de fase opsplitsen door in de aspect klasse dit eerste argument van het datatype list op te splitsen in sublijsten. Deze sublijst kan daarna als eerste argument worden geïnjecteerd in de methode, die op zich geïncapsuleerd kan worden in een thread. ParallelAspect.java … @Around("call(* *.*(..)) && @annotation(ParallelFor)”) public void generic(ProceedingJoinPoint joinPoint){ //Aantal threads bepalen en de threadpool creëren final int nThreads = Runtime.getRuntime().availableProcessors(); ExecutorService executor = Executors.newFixedThreadPool(nThreads); //Het eerste argument opvangen final Object[] arguments = joinPoint.getArgs(); List<?> captured = (List<?>)arguments[0]; //Het aantal splitsingen bepalen int size = captured.size(); final int blocksize = size/nThreads; final int rest = size%nThreads; //Een set van sublists aanmaken List<List<?>> subLists = new LinkedList<List<?>>(); subLists.add(captured.subList(0,blocksize+rest)); for (int start = 1; start < nThreads; start++) { subLists.add(captured.subList(start*blocksize+rest, start*blocksize+blocksize+rest)); } // De verdeling en uitvoering over threads for (final List<?> sublist : subLists) { arguments[0]=sublist; executor.execute(new Thread(new Runnable() { public void run() { try { joinPoint.proceed(arguments); } catch (Throwable throwable) { throwable.printStackTrace(); }}})); } executor.shutdown(); } … 48 4.4.1.2 Resultaten en bespreking De game werd telkens gerund met een oplopend aantal objecten (startende van 132 tot 1.638.400 objecten in het totaal). Het aantal overlopen iteraties in een attack-fase stijgt enorm aangezien ieder object opnieuw moet itereren over alle andere obecten. Aangezien het een visuele game is, wordt de snelheid grotendeels bepaald door twee factoren: enerzijds de game calculaties, anderzijds het renderen van de objecten. De reden waarom het nodig was om de calculaties zwaarder te maken is om te voorkomen dat het renderen de bottleneck vormt: indien de calculaties namelijk veel minder tijd innemen dan het renderen, dan wordt de maximale snelheid bepaald door de renderfase. Deze render-fase is afhankelijk van de gebruikte buffer-strategie en de grafische kaart. 164 sprites 612 sprites 1.638.400 sprites Fig 4-7 : Opdrijven van het aantal sprites in Tower Defense De game werd gerund op een Macbook met een Intel Core2 Duo CPU waarvan hyperthreading aanstaat (2 fysieke cores worden 4 virtuele cores). 49 Executietijd [ms] Calculatie executietijd single- versus multithreaded 6000 5000 4000 3000 2000 1000 0 Singlethreaded Multithreaded Fig 4-8 : Tower Defense, Calculation time single- versus multi-threaded6 Frame per seconds [FPS] In het begin is de executietijd nagenoeg gelijk. Naarmate calculaties zwaarder worden, is de snelheidswinst van parallelle executie telkens ongeveer 2.89. Een van de redenen waarom dit de verwachte snelheidswinst van 4 niet geeft is omdat de CPU hyper-threading toepast. Op zich is dit niet slecht aangezien er eigenlijk maar 2 fysieke cores zijn. Een tweede reden is natuurlijk ook de overhead die gecreëerd wordt bij het opstarten van een threadpool en afzonderlijke threads. FPS single- versus multithreaded 35! 30! 25! 20! 15! 10! 5! 0! Singlethreaded! Mulcthreaded! Fig 4-9 : Tower Defense, FPS single- versus multi-threaded6 6 Zie bijlage 4 voor datapunten 50 Wanneer de FPS worden berekend, kan er worden gezien dat multi-threaded een aanzienlijke beter performance geeft (2 tot 3 maal meer FPS). Er zou nog meer snelheidswinst kunnen worden bekomen indien de renderings-fase, meer specifieker het renderen van iedere creatur/tower, multi-threaded zou gebeuren. 4.5 Case 3 : SudokuSolver Een sudoku is een puzzelspel bestaande uit een vierkante matrix van 9x9. Die matrix is op zich dan nog eens verdeeld in groepjes van 3x3. In de vakjes moeten de cijfers 1 tot en met 9 ingevuld worden op een manier waarop zowel elke horizontale lijn als elke verticale kolom en in elk van de negen blokjes de cijfers 1 tot en met 9 een maal voorkomen. Naargelang de moeilijkheidsgraad bepaalde vakjes al ingevuld met een cijfer. Analoog bestaan er zo ook 16x16 of 25x25 sudoku’s. Er bestaan enkele algoritmes waarmee een sudoku kan worden opgelost: backtracking, brute-force, stochastisch zoeken, divide & conquer. In deze case wordt een methode voorgesteld waarbij het brute-force algoritme wordt geëxploiteerd om een parallelle uitvoering te bekomen. Backtracking Het idee achter een backtracking is om op een systematische manier alle mogelijke combinaties uit te proberen om zo tot de juiste oplossing te komen. Programmeer technisch kan dit (single-threaded) worden uitgevoerd via recursiviteit: Volgens een bepaalde volgorde wordt iedere lege cel bezocht. Het getal ‘1’ wordt ingevuld en gecontroleerd op validiteit. Wanneer dit een geldige zet is, dan wordt de volgende lege cel bezocht. Daar wordt eerst het getal ‘1’ ingevuld. Aangezien deze zet ongeldig is, wordt de waarde veranderd in een ‘2’ en opnieuw wordt validiteit gecontroleerd. Wanneer geen van de getallen 1 tot en met 9 een geldige zet zijn, dan wordt de waarde van de vorige cel verhoogd met 1. Dit gaat zo door tot de oplossing wordt bekomen. SudokuSolver.java … //Deze methode is een noodzakelijke opsplitsing om recursiviteit op een //eerste niveau te doorbreken. In tegenstelling tot het oplossings//algortime wordt er hier ook geen return verwacht. @ParallelRecursie 51 public void solve(final int[][] board, int start, int end, int col, int row, int dimension){ if (guess(board, start, end, col, row, dimension)){ System.out.println("Solution found"); } else{ System.out.println("No solution"); } } //Werkelijke oplossings-algortime public boolean guess(int[][] board, int start, int end, int col, int row, int dimension) { int nextCol = (col + 1) % dimension; int nextRow = (nextCol == 0) ? row + 1 : row; if (board[row][col] != 0){ return guess(…parameters…); } for (int i = start; i <= end; i++) { if (check(board, i, row, col, dimension)) { board[row][col] = i; if (guess(…parameters…)) { return true; } } } board[row][col] = 0; return false; } … 4.5.1.1 Generieke, parallelle Java code Aangezien er hier sprake is van recursiviteit is het een noodzaak om ergens een splitsing te kunnen maken. Deze backtracking methode kan als een boom worden voorgesteld en daarna worden gesplitst volgens enerzijds depth-first (fig 4-9), anderzijds breadth-first (fig 4-10). Bij depth-first wordt een node onderzocht door telkens zo diep mogelijk te gaan kijken naar zijn child-nodes, zonder terug te keren. Bij breadth-first per niveau gekeken welke nodes er een oplossing kunnen zijn, indien zo, dan worden de childnodes op hun beurt analoog gecontroleerd. De parallelle oplossing dat in deze case werd ontworpen is gebaseerd op het breadth-first zoekalgoritme, waardoor een verticale splitsing op het eerste niveau mogelijk wordt. 52 Sudoku Brute-force (Depth-first) 1 1 1 2 3 14 25 2 3 14 25 3 2 4 5 7 6 8 9 361 472 5831 6492 753 1864 2975 386 1 497 2 5 8 3 6194 72 5 83 6194 725 8361 94 72 5 83 6 94 7 5 8 6 9 7 361 472 5831 6492 753 1864 2975 386 1 497 2 5 8 3 6194 72 5 83 6194 725 8361 94 72 5 83 6 94 7 51 8 62 9 73 814 925 361 472 5831 6492 753 1864 2975 386 1 497 2 5 8 3 6194 72 5 83 6194 725 8361 94 72 5 83 6 94 7 5 8 6 9 7 1 8 2 9 3 Thread 1 Thread 2 14 25 8 9 361 472 5831 6492 753 1864 2975 386 1 497 2 5 8 3 6194 72 5 83 6194 725 8361 94 72 5 83 6 94 7 5 8 6 9 7 8 9 Thread 3 Fig 4-10 : Sudoku brute-force, depth-first Sudoku Brute-force (Breadth-first) 1 2 3 4 5 6 7 8 9 Thread 1 Thread 2 1 1 2 3 14 25 2 3 14 25 361 472 5831 6492 753 1864 2975 386 1 497 2 5 8 3 6194 72 5 83 6194 725 8361 94 72 5 83 6 94 7 5 8 6 9 7 361 472 5831 6492 753 1864 2975 386 1 497 2 5 8 3 6194 72 5 83 6194 725 8361 94 72 5 83 6 94 7 51 8 62 9 73 814 925 361 472 5831 6492 753 1864 2975 386 1 497 2 5 8 3 6194 72 5 83 6194 725 8361 94 72 5 83 6 94 7 5 8 6 9 7 1 8 2 9 3 14 25 8 9 361 472 5831 6492 753 1864 2975 386 1 497 2 5 8 3 6194 72 5 83 6194 725 8361 94 72 5 83 6 94 7 5 8 6 9 7 8 9 Thread 3 Fig 4-11 : Sudoku brute-force, breadth-first ParallelAspect.java … @Around("call(* *.*(..)) && @annotation(ParallelRecursie) && !codeWithinAspect()") public void recursie(ProceedingJoinPoint joinPoint) throws Throwable { final int nThreads = Runtime.getRuntime().availableProcessors(); ExecutorService executor = Executors.newFixedThreadPool(nThreads); final List<int[][]> subLists = new LinkedList<int[][]>(); final Object target = joinPoint.getTarget(); final String methodname = joinPoint.getSignature().getName(); //Capture first argument = board final Object[] arguments = joinPoint.getArgs(); int[][] board = (int[][])arguments[0]; //Generiek bepalen van ranges volgens het aantal threads final int dimension = (Integer)arguments[5]; int[][] ranges = new int[nThreads][2]; final int minvalue = (Integer)arguments[1]; int maxvalue = (Integer)arguments[2]; final int stepsize = (maxvalue - minvalue + 1) / nThreads; for (int i = 0; i <nThreads; i++){ 53 ranges[i][0] = minvalue + i*stepsize; if (i != nThreads - 1){ ranges[i][1] = ranges[i][0] + stepsize - 1; } else { ranges[i][1] = maxvalue; } } final Method method = target.getClass().getMethod(methodname, int[][].class, int.class,int.class,int.class,int.class,int.class); //Het sudoku board kopiëren for (int count = 0; count < nThreads; count++){ int[][]temp = new int[dimension][dimension]; for(int i=0; i<temp.length; i++) for(int j=0; j<temp[i].length; j++) temp[i][j]= board[i][j]; subLists.add(temp); } int count = 0; //Nieuwe argumenten implementeren en laten runnen in threads for (int[][] sublist : subLists) { final int finalCount = count; final Object[] arguments2 = {sublist, ranges[finalCount][0], ranges[finalCount][1],arguments[3], arguments[4],arguments[5]}; executor.submit(new Thread(new Runnable() { public void run() { try { method.invoke(target, arguments2); } catch (Throwable throwable) { throwable.printStackTrace(); } } })); count++; } executor.shutdown(); while (!executor.isTerminated()) {} }… 4.5.1.2 Resultaten en bespreking In deze case werd een methode voorgesteld waarmee een recursieve methode alsnog parallel kan worden uitgevoerd. De uitwerking is heel wat trivialer dan de andere cases aangezien de opsplitsing in eerste niveau handmatig werd geprogrammeerd. In die zin is er is een zekere voorkennis nodig van het algoritme. Er werd een generieke methode ontworpen waarmee zowel 9x9 als 16x16 en 25x25 sudoku’s kan worden opgelost. 54 De resultaten die bekomen werden zijn uiteenlopend. In sommige gevallen is de singlethreaded uitvoering sneller dan de parallelle uitvoering. De verklaring hiervoor (fig. 412) is te wijten aan het breadth-first algoritme waarmee een bepaalde sudoku wordt opgelost: Op voorhand kan er via de brute-force methode niet geweten worden hoe diep een bepaalde node zich in de boom zal nestelen. Dit is namelijk afhankelijk van de reeds ingevulde getallen en dus ook hoe vaak het algoritme zal worden ge-backtracked. Als voorbeeld wordt de splitsing genomen over 2 cores: Indien de oplossing van de sudoku zich in de eerste helft bevindt van de splitsing en er daarbij ook minimale backtracking gebeurd, dan zal de parallelle uitvoering minder goede resultaten geven als de singlethreaded uitvoering (aangezien er een enerzijds een overhead wordt gecreëerd door het opstarten van threads, anderzijds aangezien het geheugen een bottleneck vormt). Omgekeerd, wanneer de oplossing zich bevindt in de tweede helft dan kan de parallelle uitvoering betere resultaten geven. Sudoku Brute-force (Depth-first) 1 1 1 2 3 14 25 2 3 14 25 2 3 4 5 6 7 8 9 361 472 5831 6492 753 1864 2975 386 1 497 2 5 8 3 6194 72 5 83 6194 725 8361 94 72 5 83 6 94 7 5 8 6 9 7 361 472 5831 6492 753 1864 2975 386 1 497 2 5 8 3 6194 72 5 83 6194 725 8361 94 72 5 83 6 94 7 51 8 62 9 73 814 925 361 472 5831 6492 753 1864 2975 386 1 497 2 5 8 3 6194 72 5 83 6194 725 8361 94 72 5 83 6 94 7 5 8 6 9 7 1 8 2 9 3 Indien de oplossing zich hier bevindt, dan zal de sequentiele uitvoering beter zijn. 14 25 8 9 361 472 5831 6492 753 1864 2975 386 1 497 2 5 8 3 6194 72 5 83 6194 725 8361 94 72 5 83 6 94 7 5 8 6 9 7 8 9 De calculaties die hier gebeuren in een tweede thread zullen de oorzaak zijn van overhead. Fig 4-12 : Verklaring verschil in resultaten 55 4.6 Eindbespreking prototypes 4.6.1 Gradatie van parallellisatie De gradatie van parallellisatie is in zekere zin een percentage dat weergeeft hoe sterk code kan worden geparallelliseerd. Code waarin veel for-lussen worden gebruikt (absurdely for) en waarvan iteraties onafhankelijk zijn van elkaar zijn heel sterk parallelliseerbaar (Case2: Tower Defense). Anderzijds is code waarin bijvoorbeeld veel recursiviteit voorkomt heel moeilijk parallelliseerbaar (Case1: Tess-Modules). Wanneer wordt gesproken over deze gradatie maakt de codeerstijl (inclusief keuze van datatypes) op zich niet veel uit. Methodes en constructies kunnen namelijk op een andere manier worden geschreven, zolang de uitkomst, en dus ook doel, van het programma hetzelfde blijft. Wel zal dit invloed hebben op de mate waarin deze parallellisatie automatisch kan worden uitgevoerd: in de originele code van Tower Defense werden enkele datatypes veranderd, alsook werden er stukken code geïsoleerd in een aparte methode. 4.6.2 Realisatie van het model De prototypes werden stap voor stap opgebouwd. Als eerste stap werd bekeken hoe annotations en AspectJ het boilerplate model hebben kunnen mogelijk maken. Als tweede stap werd onderzocht hoe methodes en hun bijhorende argumenten uit de oorspronkelijke code worden opgevangen. Als laatste stap werd het prototype aangepast zodat het model zo generiek mogelijk kon worden toegepast. Hier en daar werd de oorspronkelijke code aangepast: Calculaties die parallel uitgevoerd kunnen worden, werden geïsoleerd in een aparte methode. Datatypes worden aangepast zodat splitsing kan worden toegepast. Er werd aangetoond dat snelheidswinst kan worden bekomen wanneer we multi-threaded gaan coderen. Eenmaal de aspect code werd aangemaakt, kan deze automatisch worden geïnjecteerd met behulp van een eenvoudige annotatie. Een eerste voordeel is dat de oorspronkelijke code leesbaar blijft aangezien de parallelle syntax achter de schermen gebeurt. Een tweede voordeel is dat de programmeur zijn oorspronkelijke code kan uitvoeren door simpelweg de annotatie te verwijderen. 56 4.6.3 Stagnatie in snelheid en overhead 4.6.3.1 De wet van Amdahl De wet van Amdahl [20] is één van de weinige basiswetten in de computerarchitectuur. Ze beschrijft met een eenvoudige formule de prestatiewinst van een parallel programma t.o.v. een sequentiële uitvoering, rekening houdend met het feit dat elk parallel programma ook uit een sequentieel deel bestaat. Dit is ook een van de redenen waarom een programma altijd een zekere minimale executietijd zal hebben, ongeacht het aantal cores waarop de berekeningen worden uitgevoerd. 4.6.3.2 Winst versus overhead De aanmaak van threads zorgt telkens voor een stukje overhead. Algemeen kan er worden verondersteld dat de maximale snelheidswinst kan worden bekomen wanneer het probleem wordt gesplitst zodat het aantal gelijke delen gelijke is aan het aantal fysieke cores. Wanneer dit aantal wordt overschreden wordt er een extra overhead gecreëerd aangezien een bepaalde thread zal moeten wachten tot er een vrije plaats beschikbaar wordt. 4.6.3.3 Geheugentoegang 1 Een andere mogelijke bottleneck is het geheugen. Wanneer verschillende threads op eenzelfde ogenblik het geheugen aanspreken zal een bepaalde interne policy bepalen welke thread wanneer toegang krijgt tot het geheugen. Het gebruik van cachegeheugens zorgen ervoor dat data sneller kan worden geschreven of gelezen. Het probleem bevindt zich echter bij het topic omtrent dataconsistentie: Ingelezen data moet altijd betrouwbaar en accuraat zijn. Het is dus mogelijk dat data in een cache geheugen niet de meest recentelijke update bevat. Deze data wordt in computertermen aangeduid als “invalid”. Kort gezegd zal het geheugenbeheer daarna opzoek moeten gaan naar de correcte data, en die dan updaten in het cachegeheugen. 1 Cache Memory. Geraadpleegd op 17 mei 2015 via http://homepage.cs.uiowa.edu/~ghosh/4-1-10.pdf 57 5 Toekomstbeeld 5.1 Verbeteren van het model De praktische realisatie van het model (fig 4.1) vertaalde zich in de bouw van de verschillende prototypes. De bekomen resultaten zijn op zich heel plausibel. Alhoewel de fundamenten van het model aanwezig zijn, is er zeker nog ruimte voor uitbreiding. In deze paragraaf worden enkele verbeteringen voorgesteld die theoretisch werden onderzocht maar omwille van haalbaarheid niet werden gerealiseerd in een praktische uitvoering. Als eerste wordt besproken hoe de bibliotheek kan worden aangevuld met extra patterns; als tweede wordt de scalability van het model bekeken en als laatste onderdeel wordt besproken welke meerwaarde artificiële intelligentie kan geven. 5.1.1 Aanvullen van patterns In deze thesis werden vier parallelle design patterns gerealiseerd: het absurdely-for pattern, het superscalar-sequencing pattern, het tree-builder pattern en een racer pattern. Deze bibliotheek kan dus zeker nog worden aangevuld met andere design patterns (paragraaf 3.2.1). De vraag is natuurlijk in welke mate de code specifiek moet zijn. Als voorbeeld wordt de SortRacer (prototype paragraaf 4.2.2) genomen: hier werden 5 verschillende sorteeralgoritmes geïmplementeerd. Maar wat als een programmeur een andere pattern wil gebruiken? Hoe gemakkelijk is de aspect code dan aan te passen zonder enige voorkennis? Een voorstel zou zijn om op zich de aspect klasse te voorzien van het boilerplate model: Bepaalde patterns kunnen worden opgebouwd uit vrij te kiezen modules. Deze modules kunnen vrij worden gekozen door extra parameters toe te voegen aan de annotations. 58 Bubblesort Generieke SortRacer pattern Insertionsort + = Quicksort Heapsort Generieke SortRacer pattern ... Fig 5-1 : Modulaire opbouw van parallelle patterns, boilerplate model 5.1.2 Scalability In deze thesis werden de prototypes gebouwd vanuit de veronderstelling dat de programmacode wordt uitgevoerd op een enkele multi-core computer met shared memory. Kunnen we eventueel ook de CPU’s gebruiken van een smartphone of tablet? Wat als er wordt gewenst om de berekening te maken in the cloud? Kortom, hoe kan dit model nu worden aangepast zodat deze ook kan worden gebruikt op een meer gedistribueerd systeem? Op zich bestaan er al dergelijke communicatie interfaces en tools op de markt. Een van de populairste hiervan is MPI [20]: een standaard voor een softwarebibliotheek die communicatie tussen processen vereenvoudigt en zo helpt bij het programmeren van (grootschalige) parallelle systemen. Dergelijke interfaces kunnen in principe worden samengevoegd in het model: een mastersysteem verdeelt de taken automatisch in stukjes via aspects. De taken kunnen daarna via de MPI-interface worden verstuurd naar de slaves, waarop een JVM op is geïnstalleerd. PI M Slave Master M PI Slave Fig 5-2 : Voorstel voor scalability in het model m.b.v. MPI De uitdaging hierin is eerder een politieke kwestie: in hoeverre zou bijvoorbeeld Apple het toelaten om een deel van de iPhone CPU te “ontlenen” voor externe calculaties? Amazon, bijvoorbeeld, biedt momenteel ook rekenkracht aan in de cloud. Maar in 59 hoeverre is het geheugenmodel aanpasbaar? Een dergelijke message passing interface heeft immers toegang nodig tot het geheugen. In die zin is compatibiliteit ook een probleem aangezien de architectuur van een mobiele CPU niet hetzelfde is als een desktop CPU. 5.1.3 Artificiële intelligentie In het huidig model is het de bedoeling dat de programmeur zelf op zoek gaat naar code die eventueel parallel kan worden uitgevoerd, en waarbij er een geringe afhankelijkheid is. Deze stukken code kunnen dan worden aangeduid waardoor het injecteren van parallelle code automatisch gebeurt at-compile time. In hoeverre zou het mogelijk zijn om de computer zelf te laten beslissen op welke plaatsen deze annotations zouden moeten komen? Om dit te realiseren zou het model aangepast moeten worden zodanig dat het automatisch afhankelijkheden kan onderzoeken, datatypes kan converteren en sequentiele algoritmes kan herschrijven. Het spreekt voor zich dat dit een intelligent systeem zou moeten zijn, die verder kan kijken dan enkel grammaticale constructies. Massively Parallel Artificial Intelligence [21] is een onderzoeksgebied in de artificiële intelligentie waarbij het parallel paradigma centraal staat. Hierop wordt verder niet ingegaan aangezien dit niet behoort in het domein van deze thesis. 60 5.2 Conclusie Zoals reeds werd besproken in paragraaf 2.1.2 en samengevat in fig 2-6 bepaalt de programmeur nu ook voor een stuk hoe performant een applicatie zal lopen: Enkele jaren geleden was het zo dat verbetering in performance werd gevraagd aan de hardware-fabrikanten, dit door CPU’s te ontwerpen met een steeds hogere kloksnelheid. Omwille van fysische beperking (storing in signalen naarmate de kloksnelheid verhoogt), economische verantwoordelijkheid (oplopend vermogen verbruik) en technologische evolutie (exponentiele verhoging van het aantal transistors op eenzelfde oppervlakte) was de logische stap om over te gaan naar multi-core-CPU’s. Dit wil dan ook zeggen dat de programmeur nu een grotere verantwoordelijkheid krijgt. De instap maken in parallel coderen is vaak niet gemakkelijk: Algoritmes die sequentieel heel sterk geschreven zijn moeten plots vervangen worden door een minder elegant algoritme dat parallelle uitvoering mogelijk maakt. De programmeur heeft nu ook als bijkomende taak om een oog te houden op de volgorde waarin code wordt uitgevoerd (methodes die onafhankelijk zijn kunnen bijvoorbeeld worden geïsoleerd en op een ander moment worden uitgevoerd). Aangezien deze thesis zich richt op het Object Oriented paradigma moet gedrag van objecten nu ook extra in rekening worden gebracht. Daarbij komt ook nog dat er in Java (JVM) weinig controle is op de low-level systeem access waardoor een werkelijk parallel model veel trivialer is om te implementeren. Het laatste extra aandachtspunt is het geheugenbeheer: in parallel coderen is data consistentie een grote uitdaging. Het doel is in deze thesis was om uiteindelijk een manier te vinden waarmee de programmeur met groot gemak een reeds bestaande code kan omvormen naar veilige, parallelle, object oriented code. We mikken op een zo laag mogelijke instap en een zo groot mogelijke performance-verhoging. Er werd inspiratie gedaan op het OpenMP model dat reeds jaren in ontwikkeling is: OpenMP is een interface die het toelaat om gemakkelijk multi-core applicaties te ontwikkelen en die momenteel enkel beschikbaar is voor de talen C/C#/C++ en Fortran. De basis van het model is door gebruik te maken van “#pragma’s”. Via deze compiler directives kunnen stukken code aangeduid worden, die parallel moet worden uitgevoerd. Wanneer deze pragma’s niet herkend worden door de compiler dan worden die gewoon genegeerd. In die zin is het een elegante oplossing om eenzelfde broncode te gebruiken voor zowel sequentiële uitvoering als parallelle uitvoering. Aangezien dit model omwille van verschillende redenen (paragraaf 4.1.1) 61 nog niet vertaald is naar een Object Oriented model maar toch heel krachtig en robuust is, werd er in deze thesis een vertaling gemaakt naar een Object Oriented model. Het gerealiseerde model werd bekomen door het gebruik van standaard Java bibliotheken (Java Threads, Java Reflections) en de AspectJ runtime compiler. In die zin kan iedere programmeur dit model gemakkelijk gebruiken en daardoor ook blijven programmeren in hun favoriete IDE. Er zijn drie grote stappen die het model typeren : Als eerste het isoleren en aanduiden van bestaande code die men parallel wenst uit te voeren, met behulp van annotations. Daarna zal deze code vervangen worden door het gekozen parallelle pattern. Als laatste wordt deze code via AspectJ automatisch geïnjecteerd in de bestaande code en multi-threaded uitgevoerd. De oorspronkelijke code blijft leesbaar en alle parallelle syntax en geheugencontroles gebeuren achter de schermen. De prototypes die in deze thesis werden ontworpen kunnen enerzijds volwaardig worden gebruikt, anderzijds kunnen deze ook dienen als hulpmiddel om de stap tot parallel coderen kleiner te maken. De bekomen resultaten zijn plausibel in die zin dat er snelheidswinst werd bekomen (afhankelijk van het aantal CPU’s). Een hoge graad van genericiteit werd bekomen, waardoor prototypes kunnen worden hergebruikt met eventueel minimale aanpassingen. Alsook blijft de code leesbaar en overzichtelijk door het aanduiden van parallelle code met behulp van annotations. Deze tool is open source en kan naar believen worden aangepast. Een voorstel is om een platform uit te bouwen, waarbij programmeurs hun eigen parallelle patterns kunnen delen met de community. In die zin blijft het model leven en kan de bibliotheek aan patterns zo modulair blijven groeien. Niet alle algoritmes zijn even sterk parallelliseerbaar. In paragraaf 4.5.1 werd dit besproken onder de term gradatie van parallellisatie. Het model kan hier dus niet meteen een oplossing op geven. De uitdaging om een niet parallelliseerbaar algoritme alsnog te parallelliseren is op zich een sport: Het is merkwaardig hoe levendig de community is om bijvoorbeeld een parallelle oplossing te vinden voor het oplossen van Sudoku’s (dat niet berust is op backtracking). Of hoe men nog steeds op zoek is naar een parallelle implementatie voor het berekenen van de rij van Fibonacci. De stap durven zetten naar parallel programmeren vergt dus wat energie maar de winst die eruit kan gehaald worden is enorm. Dit wil niet zeggen dat de programmeur er alleen voor staat: Naast de zelfstudie (door onder andere het joinen van communities of het volgen van tutorials) evolueert de maatschappij ook mee. The awareness is er al waardoor het slechts een kwestie van tijd is wanneer parallel coderen eerder als een basis competentie zal worden aanzien in plaats van een vrees. 62 Referenties [1] Three reasons for moving to multi-core (2009). Geraadpleegd op 20 januari via http://www.drdobbs.com/parallel/three-reasons-for-moving-to-multicore/216200386 [2] Intoduction to OpenMP (2013). Geraadpleegd op 14 februari via https://www.youtube.com/watch?v=cMWGeJyrc9w [3] How the memory cache works (2007). Geraadpleegd op 15 mei 2015 via http://www.hardwaresecrets.com/article/how-the-memory-cache-works/481 [4] The Java Memory Model. Geraadpleegd op 15 mei 2015 via http://shipilev.net/blog/2014/jmm-pragmatics/ [5] Philip A. Bernstein, Sudipto Das (2013). Rethinking Eventual Consistency [paper]. Microsoft Research Redmond [6] Java Concurrency and multi-threading (2015). Geraadpleegd op 14 maart 2015 via http://tutorials.jenkov.com/java-concurrency/index.html [7] The dining philosophers problem (2013). Geraadpleegd op 27 maart 2015 via http://adit.io/posts/2013-05-11-The-Dining-Philosophers-Problem-With-RonSwanson.html [8] Lesson Pointers in C. Geraadpleegd op 15 mei 2015 via http://www.cprogramming.com/tutorial/c/lesson6.html [9] AspectJ (2015). Geraadpleegd op 12 december 2014 via https://eclipse.org/aspectj/ [10] Project Lombok (2014). Geraadpleegd op 12 december 2014 via https://projectlombok.org [11] Java Reflections. Geraadpleegd op 23 december via https://docs.oracle.com/javase/tutorial/reflect/ [12] Rian Goossens, Tom Van Steenkiste (2015). A Survey on Design Patterns and Skeletons in Parallel Programming [paper]. Faculty of Engineering And Architecture Ghent University 63 [13] A Primer on Scheduling Fork-Join Parallelism with Work Stealing (2014). Geraadpleegd op 12 mei 2015 via http://www.openstd.org/jtc1/sc22/wg21/docs/papers/2014/n3872.pdf [14] M.D. McCool. Structured Parallel Programming with Deter- ministic Patterns [15] Barry Tannenbau (2014). Graduate from MIT to GCC Mainline with Intel® CilkTM Plus. The Parallel Universe, 2014, nr. 18, pp. 4-15. Geraadpleegd op 14 januari 2015 via https://software.intel.com/sites/default/files/managed/6a/78/parallel_mag_issu e18.pdf [16] OpenMP Architecture Review Board. OpenMP C and C++ Application Program Interface (2008). Geraadpleegd op 14 mei 2015 via www.openmp.org. [17] Michael Klemm, Ronald Veldema, Matthias Bezold, Michael Philippsen (2006). A Proposal for OpenMP for Java [paper]. University of Erlangen-Nuremberg, Computer Science Department 2. [18] Automatic parallelization tools (2014). Geraadpleegd op 23 decemeber 2014 via http://en.wikipedia.org/wiki/Automatic_parallelization_tool [19] De wet van Amdahl (2009). Geraadpleegd op 12 mei 2015 via http://www.softwareinnovators.nl/2009/03/23/de-wet-van-amdahl/ [20] Message Passing Interface (2014). Geraadpleegd op 17 mei 2015 via https://computing.llnl.gov/tutorials/mpi/ [21] Hiroaki Kitano (2006). Massively Parallel Artificial Intelligence [paper]. Carnegie Mellon University Pittsburgh 64 Bijlages Bijlage 1 Bron : http://nl.wikipedia.org/wiki/Lijst_van_Intel-processors Name Year Max frequentie (MHz) Cores Transistors (milj) 80386 1985 33 1 2,75 80486 1989 100 1 1,2 Pentium 1993 200 1 3,1 Pentium Pro 1995 200 1 5,5 Pentium MMX 1997 233 1 5 Pentium ll Celeron 1998 433 1 19 Pentium ll 1997 450 1 1,5 Itanium 2001 800 1 Pentium lll Celeron 2000 1000 1 28,1 Pentium lll 1999 1400 1 12 Itanium 2 2002 1500 1 Intel Core Solo 2006 1866 1 Intel Atom 2008 1870 2 Pentium M 2003 2260 1 144 Intel Core Duo 2006 2333 1 151 Intel Core i5 2009 2666 4 Intel Core 2 Quad 2007 3000 4 Intel Core i3 2010 3066 2 Pentium 4 Cereron 2000 3200 1 125 Intel Core 2 E 2006 3200 2 291 Intel Core i7 Intel Core 2 Quad Xeon 2008 3222 4 800 2008 3222 8 Intel Core 2 Duo 2006 3333 2 291 Intel Core i7 980X 2010 3333 6 1170 Pentium 4 EE 2005 3400 1 130 Pentium D 2005 3400 2 376 Intel Celeron D 2006 3460 1 Pentim 4 EE 2005 3733 2 Pentium 4 2000 3800 1 Intel Core i7 EE 2009 3860 4 Intel Core i7 2 2011 3900 4 1160 Intel Core i7 3 2012 3900 4 1400 Intel Core i7 4 2013 3900 4 Intel Core i7 Xeon 2010 4066 4 151 582 55 65 Bijlage 2 ParallelFor # Threads Execution time on Intel Corde 2 Duo (100 calculations) 1 145 2 102 4 75 8 74 16 73 32 71 64 82 128 81 256 81 Bijlage 3 RacerSort # Elements RACER (ms) MERGE (ms) INSERT (ms) SELECTION (ms) BUBBEL (ms) 256 8 14 6 7 15 512 14 17 9 7 22 1024 18 22 14 9 51 2048 28 33 20 17 103 4096 35 50 29 25 213 8192 87 63 52 49 753 16384 191 159 160 155 2709 32768 330 263 519 543 11287 65536 517 437 1842 1970 46314 131072 490 570 8482 7773 262144 697 699 34392 31014 Multi-threaded Selection 8 9 WINNER Insert 21 14 Selection 18 19 Merge 28 32 Insert 58 35 Insert 112 87 Merge 191 196 Merge 330 572 Merge 517 1949 Merge 490 7433 Merge 697 34948 66 Bijlage 4 Tower Defense Singlethread CPS Creature Towers 32 100 132 3200 64 25 18 33 64 100 164 6400 77 33 12 28 128 100 228 12800 118 44 8,9 20 256 100 356 25600 160 61 5,87 15,79 512 100 612 51200 289 99 3,3 8,99 1024 100 1124 102400 529 182 1,87 4,93 2048 100 2148 204800 989 388 0,9 2,55 4096 100 4196 409600 2002 752 0,49 1,26 8192 100 8292 819200 3960 1494 0,25 0,69 16384 100 16484 1638400 4895 3223 0 0,29 Iter. Multi-thread CPS Singlethread FPS Total sprites Multi-thread FPS 67 !