Προγραμματισμός Συστημάτων Υψηλών Επιδόσεων (ΗΥ421) Εργασία Εξαμήνου ΟΜΑΔΑ: Ιωαννίδης Σταύρος ΑΕΜ: 755 Ντελής Γιώργος ΑΕΜ: 726 Επιλογή της Εργασίας Για την εργασία μας επιλέξαμε την βελτιστοποίηση της απόδοσης, με εκτέλεση τμημάτων κώδικα σε GPU, του kernel FFT από την σουίτα παραλλήλων benchmarks splash2. Ο κώδικας του FFT εφαρμόζει τον αλγόριθμο του Fast Fourier Tranform πάνω σε πίνακα που περιέχει ψευδοτυχαίους αριθμούς. Χρησιμοποιήσαμε την παράλληλη υλοποίηση του κώδικα. Για να έχουμε έναν αισθητό χρόνο εκτέλεσης, εκτελέσαμε τον FFT με παράμετρο m=26 που ορίζει την τάξη μεγέθους του προβλήματος. Τις λοιπές παραμέτρους τις αφήσαμε στις προεπιλεγμένες τους τιμές. Για ένα νήμα (p=1) πήραμε συνολικό χρόνο εκτέλεσης 17,6sec. Στις περιοδικές μας μετρήσεις χρησιμοποιήσαμε (για τον nvcc) βαθμό βελτιστοποιήσεων -Ο4 διότι έδωσε ελάχιστα ταχύτερη εκτέλεση από τις άλλες επιλογές. Επίσης για να έχουμε μια καλύτερη εικόνα των επιπτώσεων που έχουν οι αλλαγές μας στον χρόνο εκτέλεσης: 1. απενεργοποιήσαμε τις L1 και L2 caches 2. πήραμε τοπικές μετρήσει χρόνου με gettimeofday (αντί του συνολικού χρόνου εκτέλεσης) 3. προσθέσαμε dummy kernel ο χρόνος του οποίου κυμαίνεται από 200ms μέχρι 500ms. Μετά από κάθε μας αλλαγή στον κώδικα ελέγξαμε την ορθότητα των αποτελεσμάτων με την χρήση του μηχανισμού checksum testing που υπήρχε στον κώδικα (επιλογή -t κατά την εκτέλεση). Για μεγαλύτερη σιγουριά βάλαμε τον αντίστροφο FFT (όπου direction=-1) που χρησιμοποιείται στο testing, να γίνεται πάντα στην CPU. Χαρακτηριστικά της GPU μας (devicequery) Για την ανάπτυξη της εργασίας μας χρησιμοποιήσαμε τον inf-zeus1 και την GeForce GTX 480 τα χαρακτηριστικά της οποίας είναι:
Profiling Το VTUNE έδειξε την FFD1DOnce (55% του χρόνου εκτέλεσης) και την Transpose (38%) ως τις πιο αργές συναρτήσεις: Το GPROF έδειξε και το πλήθος των εκτελέσεων για κάθε μια από αυτές τις συναρτήσεις όπως φαίνεται στο παρακάτω σχήμα που πήραμε με το gprof2dot: Compile με NVCC Για την μεταγλώττιση με nvcc χρησιμοποιήσαμε τα αρχικά Makefiles και φτιάξαμε το παραδοτέο Makefile, στο οποίο θα πρέπει να τροποποιηθεί κατάλληλα το path του BASEDIR ώστε να δείχνει στον φάκελο splash2. Η εκτέλεση γίνεται δίνοντας:./fft -m26 -p1 -n65536 -l4 Ο τροποποιημένος κώδικάς μας βρίσκεται στο αρχείο fft.c. Για την μεταγλώττιση και εκτέλεση του αρχικού κώδικα του fft μετονομάστε το αρχείο original_fft_code_dot_c σε fft.c.
Port σε GPU της πιο αργής συνάρτησης: FFD1DOnce Ξεκινώντας με την FFD1DOnce που είναι και η πιο αργή συνάρτηση του κώδικα, παρατηρήσαμε ότι υπάρχουν 2 for μέσα από τα οποία καλείται συνολικά 2*8192 φορές (μέσα στο σώμα της συνάρτησης FFT1D): Αν τρέξουμε με ένα νήμα τότε αυτό αναλαμβάνει και τις 2*8192 εκτελέσεις και επεξεργάζεται όλον τον πίνακα εισόδου, ενώ με περισσότερα κάθε νήμα αναλαμβάνει την επεξεργασία ενός μόνο τμήματος του αρχικού πίνακα (π.χ. Για 4 νήματα το κάθε νήμα εκτελεί 2048 φορές την FFD1DOnce σε κάθε for). Η επεξεργασία αυτή του κάθε νήματος γίνεται παράλληλα οπότε η πρώτη μας κίνηση ήταν η μετατροπή της συνάρτησης FFD1DOnce σε global και η εκτέλεσή της σε GPU με 8192 threads, με κάθε thread να αναλαμβάνει την δουλειά μίας μόνο εκτέλεσης της FFD1DOnce: Έτσι αντικαταστήσαμε αρχικά μόνο το πρώτο for με αυτόν τον kernel, κάνοντας φυσικά και τις απαραίτητες δεσμεύσεις/αποδεσμεύσεις και μεταφορές μνήμης. Οι πρώτες μετρήσεις στον χρόνο εκτέλεσης έδειξαν: CudaMalloc 0,3 CudaFree 13 CudaMemcpy to Device 341 CudaMemcpy to Host 324 CudaMemcpy Total 665 FFD1DOnce on GPU 2567 1000 FFD1DOnce on CPU 5497 0 Speedup 1,7x on GPU on CPU *οι χρόνοι είναι σε msec *στον υπολογισμό του speedup περιλαμβάνεται ο χρόνος εκτέλεσης σε GPU και ο χρόνο μεταφοράς δεδομένων από και προς αυτήν ενώ δεν περιλαμβάνεται ο χρόνος δέσμευσης/αποδέσμευσης μνήμης Για την αντικατάσταση και του δεύτερου for που καλεί την FFT1DOnce με τον παραπάνω kernel, θα πρέπει να μεταφέρουμε στην device τον πίνακα x. Για να γλιτώσουμε τις περιττές δεσμεύσεις και αποδεσμεύσεις μνήμης και μιας και οι πίνακες scratch και x είναι ίδιου μεγέθους, απλά θα χρησιμοποιήσουμε τον χώρο που ήδη είχαμε δεσμεύσει με cudamalloc για τον scratch, αλλά αυτή την φορά θα τοποθετήσουμε εκεί τα δεδομένα του x. Οι χρόνοι για τον δεύτερο kernel είναι: Χρόνος σε msec 6000 5000 4000 3000 2000 execution memory transfers CudaMemcpy to Device 341 CudaMemcpy to Host 324 CudaMemcpy Total 665 FFD1DOnce on GPU 2561 FFD1DOnce on CPU 2640 Speedup 0,818x Χρόνος σε msec 3500 3000 2500 2000 1500 1000 500 0 on GPU on CPU execution memory transfers
Παρά το γεγονός ότι ο χρόνος εκτέλεσης χειροτερεύει, θα κρατήσουμε την αλλαγή αυτή διότι είναι πολύ πιθανό ο χρόνος να βελτιωθεί αρκετά στην συνέχεια και να δώσει speedup μεγαλύτερο του 1x. Λόγω των πολλών καταχωρητών που χρησιμοποιεί η FFD1DOnce αλλά και λόγο του ότι δεν γίνεται κάποια προφανής εκμετάλλευση της block-wise αρχιτεκτονικής της GPU, δοκιμάσαμε διάφορες διαστάσεις για τα blocks και ταχύτερη αποδείχθηκε η 64 threads ανά block. Παρατηρούμε πως αρκετός χρόνος ξοδεύεται στα memory transfers διότι ο πίνακας scratch που χρειάζεται η FFD1Donce είναι λίγο μεγαλύτερος από 1GB. Πέρα από τον scratch, υπάρχουν άλλοι δύο πίνακες του ίδιου μεγέθους οι x και umain2 που χρησιμοποιούνται στην FFD1D. Παρακάτω φαίνονται πιο αφαιρετικά οι συναρτήσεις με τη σειρά που καλούνται από την FFD1D καθώς και ποιοι από τους 3 αυτούς (μεγάλους) πίνακες (scratch, x και umain2) χρειάζεται η κάθε μια: FFD1D: Transpose(... x, scratch,... ); FFT1DOnce(... scratch,...); TwiddleOneCol(... umain2, scratch,... ); Transpose(... scratch, x,... ); FFT1DOnce(... x,... ); Scale(... x,...); Transpose(... x, scratch,... ); CopyColumn(... scratch, x,... ); Όπως είναι λογικό, κάθε κλήση της FFD1DOnce απαιτεί 2 μεταφορές της τάξης του 1GB: μια του πίνακα εισόδου προς την device πριν την κλήση και μια του τροποποιημένου πίνακα προς τον host μετά την κλήση. Ακόμα και αν τρέχαμε στην GPU όλες τις ενδιάμεσες συναρτήσεις που περιλαμβάνονται μεταξύ των δύο κλήσεων της FFD1DOnce αποφεύγοντας της ενδιάμεσες περιττές μεταφορές, δεν θα ήταν δυνατόν να έχουμε και τους τρεις αυτούς πίνακες αποθηκευμένους στην global memory την ίδια χρονική στιγμή μιας και ξεπερνούν τα 3GB συνολικά (ενώ η device έχει μόνο 1,5GB). Συνεπώς τα περιττά memory transers δεν γίνεται να αναληφθούν στην παρούσα φάση. Μετατροπή από double σε single precision Μια δοκιμή που κάναμε ήταν η χρήση float αριθμών αντί για doubles που χρησιμοποιούνταν στην αρχική υλοποίηση του FFT. Εκτελώντας για διάφορες τιμές της παραμέτρου m, και ενεργοποιώντας την λειτουργία του checksum testing, είδαμε πως το μεγαλύτερο checksum difference που πήραμε ήταν 2 για m=24 (διαφορά στα checksums μικρότερη του 0,000001%) το οποίο θα μπορούσαμε να το θεωρήσουμε αμελητέο. Οι επιπτώσεις της χρήσης των floats στον χρόνο εκτέλεσης ήταν θετικές, αφού ο χρόνος δέσμευσης μνήμης μειώθηκε κατά 25% και ο χρόνος μεταφορών κατά 50%, αποτέλεσμα λογικό αφού πλέον μεταφέρεται ακριβώς η μισή ποσότητα δεδομένων από πριν. Ο χρόνος της global FFD1DOnce μειώθηκε ελάχιστα από 2,56sec σε 2,50sec. Στην προσπάθειά μας να ερμηνεύσουμε την τόσο μικρή βελτίωση στον χρόνο εκτέλεσης, καταλήξαμε στο συμπέρασμα ότι οι Fermi αρχιτεκτονικές υποστηρίζουν πλήρως double precision πράξεις, ο χρόνος εκτέλεσης των οποίων είναι παραπλήσιος αυτού των single precision. Μετά τις αναβαθμίσεις μάλιστα του λογισμικού που έγιναν στον inf-zeus1 τον Αύγουστο, δεν είναι καν απαραίτητη η χρήση της παραμέτρου -arch=sm_11 κατά την μεταγλώττιση (με nvcc) κώδικα που περιέχει double precision πράξεις.
Βελτισποποιήσεις σχετικές με off-chip memory Η χρήση των floats έδωσε χώρο για περισσότερες βελτιστοποιήσεις. Παρατηρούμε πως ο πίνακας upriv που χρησιμοποιεί η FFT1DOnce έχει τώρα μέγεθος 64ΚΒ και επίσης δεν τροποποιείται από την συνάρτηση (είναι δηλαδή read-only). Για τον λόγο αυτό δοκιμάσαμε να τον τοποθετήσουμε στην constant memory της device. Πήραμε μια μικρή βελτίωση που φαίνεται στους παρακάτω χρόνους: Πίνακας upriv στην Global Memory Constant Memory Εκτέλεση 1η 2η Σύνολο 1η 2η Σύνολο CudaMemcpy to Device 170 170 340 170 170 340 CudaMemcpy to Host 162 162 324 162 162 324 CudaMemcpy Total 332 332 664 332 332 664 FFD1DOnce on GPU 2502 2513 5015 2260 2255 4515 FFD1DOnce on CPU 5497 2640 8137 5497 2640 8137 Speedup 1,94x 0,93x 1,43x 2,12x 1,02x 1,57x Βελτιστοποιήσεις σχετικές με on-chip memory Στην προσπάθειά μας να εκμεταλλευτούμε την shared memory της device, παρατηρήσαμε ότι κάθε thread μπορεί να προσπελάσει κάποιες θέσεις του πίνακα εισόδου της FFD1DOnce, μέσα σε ένα φάσμα 64KB: το οποίο είναι αρκετά μεγάλο για να χωρέσει στην shared memory κάθε block. Ακόμα όμως και αν η shared memory ήταν αρκετά μεγάλη (πιθανώς σε μελλοντικές κάρτες γραφικών) δεν θα ήταν σοφό να την χρησιμοποιήσουμε αφού τα threads ενός block θα έπρεπε να φέρουν (συνεργατικά) στην shared αυτά τα 64ΚΒ ενώ μπορεί να χρησιμοποιήσουν πολύ πολύ λιγότερα. Ακόμα λόγω της παράλληλης υλοποίησης του FFT, κάθε thread πειράζει διαφορετικές θέσεις του πίνακα εισόδου οπότε κάθε byte από αυτά τα 64ΚΒ θα μπορεί να προσπελαστεί το πολύ μια φορά και από ένα μόνο thread. Το μόνο που μπορούμε να κάνουμε για να εκμεταλλευτούμε κάποια on-chip memory είναι στο τέλος όλων των βελτιστοποιήσεων μας, να ενεργοποιήσουμε το caching και να δώσουμε περισσότερο χώρο στην L1 cache.
Βελτιστοποιήσεις της global FFD1DOnce Το πρώτο που κοιτάξαμε στην υλοποίηση της global FFD1DOnce ήταν η χρήση καταχωρητών για την αποθήκευση δεδομένων που διαβάζονται από την global memory και χρησιμοποιούνται περισσότερες από μια φορές, όμως αυτό ήταν ήδη υλοποιημένο στον αρχικό κώδικα του FFT. Αντικαταστήσαμε τις πολλαπλές και χρονοβόρες πράξεις πολλαπλασιασμού και διαίρεσης με ολισθήσεις, όπου αυτό ήταν δυνατόν. Πήραμε μια μικρή βελτίωση στον χρόνο εκτέλεσης του kernel από 2,26sec σε 2,24sec. Port σε GPU της 2ης πιο αργής συνάρτησης: Transpose Μετά την αλλαγή σε floats, κάθε ένας από τους πίνακες scratch, x και umain2 έχει το μισό μέγεθος από πριν δηλαδή λίγο περισσότερο από 0,5GB. Τώρα μπορούμε να έχουμε δύο από αυτούς τους πίνακες αποθηκευμένους στην device την ίδια χρονική στιγμή. Αυτό μας επιτρέπει να προχωρήσουμε στην μετατροπή και εκτέλεση στην GPU συναρτήσεων που χρησιμοποιούν δύο από αυτούς τους πίνακες. Επιλέξαμε να κάνουμε global την συνάρτηση Transpose διότι είναι η δεύτερη πιο αργή συνάρτηση και καταλαμβάνει ένα σημαντικό ποσοστό του χρόνου εκτέλεσης. Η χωρισμός του νέου μας kernel σε blocks είναι ίδιος με πριν δηλαδή 64 threads ανά block. Δοκιμάσαμε να αντικαταστήσουμε με τον νέο μας kernel τις δύο πρώτες φορές που καλείται η Transpose ενώ αφήσαμε την τρίτη της εκτέλεση να γίνεται στην CPU. Παρατηρήσαμε πως κατά την πρώτη κλήση της Transpose ο πίνακας scratch είναι μηδενισμένος, συνεπώς για να γλιτώσουμε την μεταφορά, μετά την δέσμευσή του χρησιμοποιήσαμε την cudamemset για να τον μηδενίσουμε στην device. Επειδή στις Fermi αρχιτεκτονικές οι kernels εκτελούνται παράλληλα, χρησιμοποιήσαμε την cudathreadsynchronize μετά τις κλήσεις της Transpose για να εξαναγκάσουμε τους kernel της Transpose και της FFD1DOnce να εκτελεστούν σειριακά ο ένας μετά τον άλλον. Δεν χρειάστηκε να κάνουμε το ίδιο και μεταξύ της πρώτης κλήσης της FFD1DOnce και της δεύτερης της Transpose διότι παρεμβάλλεται πράξη cudamemcpy που όπως γνωρίζουμε είναι σύγχρονη. Οι μετρήσεις χρόνου για την Transpose χωρίς μεταφορές μνήμης δείχνουν βελτιωμένο χρόνο από 1,1sec (στη CPU) σε 0,1sec (στη GPU). Στην προσπάθειά μας να απαλείψουμε τα cudathreadsynchronize, ενοποιήσαμε τους δύο kernel μας Transpose και FFD1DOnce σε έναν, προσπαθώντας να ελαχιστοποιήσουμε ταυτόχρονα και τον αριθμό των χρησιμοποιούμενων καταχωρητών επαναχρησιμοποιώντας τους όπου ήταν εφικτό. Δεν ήταν δυνατόν να πάρουμε μέτρηση χρόνου με την gettimeofday λόγω του ότι η κλήση του kernel επέστρεφε άμεσα εξ αιτίας της έλλειψης των cudathreadsynchronize, οπότε χρησιμοποιήσαμε την time κατά την εκτέλεση του προγράμματος. Παραδόξως η υλοποίηση με τους δύο ξεχωριστούς kernel ήταν ταχύτερη με χρόνο περίπου 13,5sec ενώ η υλοποίηση με τον ενοποιημένο kernel έδωσε χρόνο 14,6 sec. Συνεπώς κρατήσαμε την παλιά μας υλοποίηση. Έχοντας φτάσει σε έναν αρκετά μικρό χρόνο για την Transpose, ξαναενεργοποιήσαμε τις L1 και L2 caches και δώσαμε 48ΚΒ στην L1 cache με την εντολή cudafuncsetcacheconfig. Μετρήσαμε τον συνολικό χρόνο με την time ο οποίος βελτιώθηκε από 13sec σε 11,3 sec. Το συνολικό speedup που έχουμε επιτύχει από τα 17,6sec του αρχικού κώδικα είναι 1,558x (φυσικά στον χρόνο αυτόν περιλαμβάνονται και οι χρόνοι του dummy kernel, των cudamalloc και cudafree).
Στο παρακάτω γράφημα φαίνονται συγκεντρωτικά οι θετικές επιδράσεις που είχαν οι βελτιστοποιήσεις μας (με τη σειρά που τις εφαρμόσαμε) στον συνολικό αλλά και στους επιμέρους χρόνους: 20000 18000 16000 14000 12000 Χρόνος σε msec 10000 8000 6000 4000 rest FFT1DOnce execution Transpose execution cuda memcpy to host cuda memcpy to device cuda malloc and free 2000 0 CPU Port of FFD1DOnce Double to Float Using Constant mult/div to shift Port of Transpose Using L1/L2 Βελτιστοποιήσεις με την σειρά που εφαρμόστηκαν Δοκιμάσαμε να τρέξουμε και για μικρότερες τιμές του m και να συγκρίνουμε τα αποτελέσματα με αυτά του αρχικού κώδικα του FFT, τα οποία φαίνονται στο παρακάτω γράφημα: 100 10 4,2 2,5 17,6 11,3 1 0,2 0,3 0,2 0,8 0,7 on CPU on GPU 0,1 0,05 0,01 18 20 22 24 26 Παρατηρώντας το γράφημα γίνεται εύκολα αντιληπτό πως σε μελλοντικές κάρτες γραφικών με μεγαλύτερες μνήμες (όπου θα μπορούμε να τρέξουμε για m>26) θα παίρνουμε ακόμα μεγαλύτερα Speedup από αυτά που έχουμε ήδη πάρει.