PDA

View Full Version : Performance Tip: Array Preload


DarthMoul
26-07-2004, 09:06
Πριν μιλήσουμε για το θέμα του τίτλου, θα πρέπει να πούμε μερικά χρήσιμα πράγματα για το cache.

Όταν ο memory controller συναντάει ένα cache miss στο L1 cache, ξεκινάει αναζήτηση στο L2, το L3 και τελικά στην μνήμη μέχρι να βρει τα δεδομένα που ζητήσαμε, να τα μεταφέρει στους registers για επεξεργασία, και φυσικά να τα αποθηκεύσει στο cache για χρήση στο άμεσο μέλλον αν χρειαστεί. Ενδεικτικά το κόστος αυτής της διαδικασίας είναι όπως παρακάτω:

Pentium 4 3.2 EE
L1 latency = 2 cycles
L2 latency = 19 cycles
L3 latency = 43 cycles
RAM latency = 206 cycles

Athlon 64 3400+
L1 latency = 3 cycles
L2 latency = 13 cycles
RAM latency = 101 cycles

Το L1 cache είναι πάντα χωρισμένο σε 2 ίσα μέρη. Το Dcache (Data cache) στο οποίο αποθηκεύονται οι τελευταίες μεταβλητές που προσπελάσαμε, και το Icache (Instruction cache) στο οποίο αποθηκεύονται οι τελευταίες εντολές που εκτελέσαμε. Η μεταφορά δεδομένων από την μνήμη στο Dcache δεν γίνεται μεμονωμένα για τις μεταβλητές που ζητάμε, αλλά σε ομάδες από συνεχόμενα bytes που ονομάζονται cache lines. Τι μέγεθος έχουν τα cache lines; Για τους P3 είναι 32 bytes και για όλους τους AMD επεξεργαστές καθώς και για τους P4 είναι 64 bytes.

Αυτό σημαίνει ότι αν θέλουμε να δουλέψουμε με έναν πίνακα ακεραίων, για να κάνουμε το preload, αρκεί να «αγγίξουμε», ένα στοιχείο του ανά 16, και ολόκληρος ο πίνακας θα βρεθεί μέσα στο cache. Αν είχαμε να δουλέψουμε με πίνακα χαρακτήρων, θα αρκούσε να «αγγίξουμε» ένα Byte ανά 64.

Παράλληλα όμως υπάρχει και ένας περιορισμός. Σε κάθε δεδομένη στιγμή, έχουμε πρόσβαση στα δεδομένα ενός μόνο cache line. Αν τα δεδομένα που χρειαζόμαστε είναι μοιρασμένα σε περισσότερα από ένα cache lines, τότε λέμε ότι έχουμε την περίπτωση του cache line split. Αυτό σημαίνει ότι για την χρήση κάθε επιπλέον cache line καλούμαστε να πληρώσουμε το κόστος προσπέλασης του L1 cache που όπως βλέπουμε πιο πάνω είναι 2 κύκλοι για τους P4EE και 3 κύκλοι για τους A64. Αυτός ο περιορισμός, μπορεί εν μέρει και υπό συνθήκες να παρακαμφθεί αλλά θα το συζητήσουμε κάποια άλλη στιγμή.

Για προσπέλαση δεδομένων που έχουν μέγεθος μικρότερο από αυτό του cache line, καλό θα ήταν να προτιμούμε μεγέθη που είναι ακέραια υποπολλαπλάσια του cache line size. Για παράδειγμα, αν πρέπει να επεξεργαστούμε έναν πίνακα από strings των 10 bytes, από πλευράς ταχύτητας μας συμφέρει να δουλέψουμε με strings των 16 bytes γιατί έτσι θα αποφύγουμε το κόστος του cache line split. Αυτό βέβαια εις βάρος της μνήμης μας, αφού τα 6 τελευταία bytes κάθε string θα μείνουν άχρηστα. Για δεδομένα με μέγεθος μεγαλύτερο του cache line, μας συμφέρει το ακέραιο πολλαπλάσιο του cache line size. Έτσι θα μειώσουμε το κόστος του cache line split έως και 50%. Αν πχ πρέπει να δουλέψουμε με έναν πίνακα από records που έχουν μέγεθος 97 bytes το καθένα, το καλύτερο που έχουμε να κάνουμε είναι να προσθέσουμε στο τέλος κάθε record όσα bytes χρειάζεται για να φτάσουμε τα 128 bytes.

DarthMoul
26-07-2004, 10:18
Αν υποθέσουμε ότι έχουμε δύο πίνακες από 256 ακεραίους ο καθένας και θέλουμε τα αθροίσματα των αντιστοίχων θέσεων μέσα σε έναν τρίτο πίνακα, θα πρέπει να κάνουμε κάτι σαν αυτό:

for(i = 0; i < 256; i = i + 1)
c[i]=a[i] + b[i];

Για τους λόγους που είπαμε στο προηγούμενο thread, μπορούμε να κερδίσουμε χρόνο, κάνοντας preload τους τρεις πίνακες ως εξής:


/* Το κάθε cache line έχει 64 bytes και το μέγεθος του ακεραίου είναι 4 Bytes. Αρκεί να προσπελάσουμε ένα στοιχείο του πίνακα ανά 16, για να ανέβει όλος ο πίνακας στο L1 cache */
for(i=0; i< 256; i = i + 16)
{
t=a[i];
t=b[i];
t=c[i];
}

for(i = 0; i < 256; i = i + 1)
c[i]=a[i] + b[i];


Αν τα δεδομένα μας είναι περισσότερα από όσα θα χωρούσε το Dcache, τότε θα πρέπει το preload να γίνει τμηματικά.

DarthMoul
26-07-2004, 11:40
Τα εγχειρίδια της intel προτείνουν την χρήση ζευγών ακεραίων, ή γενικότερα ζευγών με μέγεθος ακέραιο υποπολλαπλάσιο του cache line. Αυτό είναι ένα έξυπνο κόλπο για να μειώσουμε ακόμα περισσότερο την αρνητική επίδραση του cache line split.
Άλλωστε η γραμμή c[i] = a[i] + b[i] προσπελαύνει τρία διαφορετικά cache lines.
Αυτό που θα μπορούσαμε να κάνουμε είναι να δημιουργήσουμε έναν πίνακα 512 θέσεων, όπου στις μονές θέσεις θα έχουμε τα περιεχόμενα του πίνακα a και στις ζυγές του b. Κατά συνέπεια το πρόγραμμα μας θα γίνει:

for(i=0; i< 256; i = i+16)
{
t=c[i];
t=k[i*2];
t=k[i*2+16];
}

for(i = 0; i < 256; i = i + 1)
c[i]=k[i*2] + k[i*2+1];

Εδώ τα k[i*2] και k[i*2+1] βρίσκονται στο ίδιο cache line οπότε προσπελαύνουμε μόνο 2 cache lines αντί για τρείς όπως πριν κερδίζοντας αντίστοιχα χρόνο.

DarthMoul
26-07-2004, 11:48
Τέλος, θα πρέπει να προσέξουμε το preload των πολυδιάστατων πινάκων. Η λογική παραμένει η ίδια, μόνο που εδώ θα πρέπει να γνωρίζουμε το memory pattern που υλοποιεί ο compiler στους πολυδιάστατους πίνακες πριν κάνουμε και το preload και την επεξεργασία. Για παράδειγμα, σε έναν δισδιάστατο πίνακα NxΜ, θα έχουμε άλλους χρόνους προσπέλασης αν τον χειριστούμε κατά γραμμή και εντελώς διαφορετικούς να τον χειριστούμε κατά στήλη. Το μόνο που μπορεί να μας φωτίσει είναι το manual του compiler. Γι αυτό τον λόγο, καλό είναι να αποφεύγουμε τους πολυδιάστους πίνακες όσο αυτό είναι δυνατόν. Σε διαφορετική περίπτωση, μια αλλαγή compiler, μπορεί να μας υποχρεώσει να ανοίξουμε ξανά τον κώδικά μας για να τον προσαρμόσουμε στις απαιτήσεις του νέου compiler, έτσι ώστε να μην έχουμε πτώση στις επιδόσεις της εφαρμογής μας.