Samnang Nop van programmacode Optimale parallellisatie bij het

advertisement
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
!
Download