Θέλω να δείξω ένα πράγμα (C++ σε κώδικα μηχανής) – Bits’n’Bites

0
Θέλω να δείξω ένα πράγμα (C++ σε κώδικα μηχανής) – Bits’n’Bites

Εάν είστε σαν εμένα και αφιερώνετε πολύ χρόνο κοιτάζοντας τα αποτελέσματα της γλώσσας assembly από μεταγλωττιστές, αυτό το άρθρο ενδέχεται να μην παρέχει πολλές νέες πληροφορίες. Εάν όχι, ωστόσο, ελπίζουμε ότι θα το βρείτε ενδιαφέρον.

Αυτό που θέλω να δείξω είναι πόσο φαινομενικά μακρύς και πολύπλοκος κώδικας C++ μπορεί να μεταγλωττιστεί σε πολύ συμπαγή κωδικός μηχανής.

Ο κώδικας C++

Το απόσπασμα κώδικα C++ που θα μεταγλωττίσουμε ορίζει δύο δημόσιες λειτουργίες, get_exponent() και set_exponent()που εξάγουν και χειρίζονται το εκθετικό μέρος του an IEEE 754 κινητής υποδιαστολής μονής ακρίβειας αριθμός.

Εν ολίγοις, αυτό που πρέπει να κάνουμε είναι:

  • Μετατροπή μεταξύ τύπων κινητής υποδιαστολής και ακέραιου αριθμού, χρησιμοποιώντας τύπου λογοπαίγνιο, προκειμένου να έχετε πρόσβαση και να χειριστείτε τα bit εκθέτη της δυαδικής αναπαράστασης IEEE 754. Αυτό το κάνουμε με ένα έθιμο λειτουργία προτύπου, raw_cast().
  • Μετατοπίστε και αποκρύψτε τη δυαδική αναπαράσταση της τιμής κινητής υποδιαστολής, ώστε να εξαγάγετε/εισαγάγετε τα bit του εκθέτη από/στην σωστή θέση στην ακέραια λέξη.
  • Σημειώστε ότι το τμήμα τύπου punning υλοποιείται με την κλήση του προτύπου std::memcpy() λειτουργία, η οποία είναι η σωστός τρόπο να γίνει αυτό (χωρίς να υπόκεινται σε απροσδιόριστη συμπεριφορά).

Έτσι φαίνεται ο κώδικας C++:

#include <cstdint>
#include <cstring>

namespace {

// Type punning cast.
template <typename T2, typename T1>
T2 raw_cast(const T1 x)
{
  static_assert(sizeof(T1) == sizeof(T2));

  // One valid way of doing a type punning cast in
  // C/C++ is to use memcpy().
  T2 y;
  std::memcpy(&y, &x, sizeof(T2));

  return y;
}

}  // namespace

// Extract the exponent from an IEEE 754 binary32
// floating-point value.
uint32_t get_exponent(float x)
{
  auto xi = raw_cast<uint32_t>(x);

  // Extract the 8-bit exponent (bits 23..30).
  auto exp = (xi >> 23) & 0xffU;

  return exp;
}

// Replace the exponent of an IEEE 754 binary32
// floating-point value.
float set_exponent(float x, uint32_t exp)
{
  auto xi = raw_cast<uint32_t>(x);

  // Insert the 8-bit exponent into bits 23..30.
  const auto MASK = 0x7f800000U;
  auto yi = (xi & ~MASK) | ((exp << 23) & MASK);

  auto y = raw_cast<float>(yi);

  return y;
}

Ως άσκηση, προσπαθήστε να καταλάβετε πώς θα ήταν ο (μεταγλωττισμένος) κώδικας γλώσσας συγκρότησης που θα προκύψει (δηλαδή περίπου ποιες οδηγίες της CPU θα χρησιμοποιηθούν για την υλοποίηση του παραπάνω κώδικα).

Με την πρώτη ματιά φαίνεται ότι θα χρειαστεί να κάνουμε έναν αριθμό λογικών και μετατοπίσεων σε κάθε συνάρτηση, μαζί με ενσωματωμένα αντίγραφα της συνάρτησης raw_cast() και, ουχ, κλήσεις και στην τυπική συνάρτηση βιβλιοθήκης C memcpy(), η οποία πιθανώς απαιτεί την αποθήκευση των τιμών κινητής υποδιαστολής και ακεραίων στη στοίβα ή κάτι τέτοιο…

Ας δούμε όμως τι συμβαίνει στην πραγματικότητα.

Ο κώδικας της γλώσσας assembly

Χρησιμοποίησα το GCC 12 (τρέχων κορμός) και μεταγλωττίζω τον κώδικα C++ για το MRISC32 σύνολο εντολών, χρησιμοποιώντας το επίπεδο βελτιστοποίησης -O2.

Αυτός είναι ο κώδικας γλώσσας assembly που λαμβάνουμε:

get_exponent:
    ebfu  r1, r1, #<23:8>  ; Extract bit field (unsigned)
    ret                    ; Return

set_exponent:
    ibf   r1, r2, #<23:8>  ; Insert bit field
    ret                    ; Return

Απλώς για να είμαστε σαφείς: Αυτό που εξετάζουμε εδώ είναι το πλήρης και σωστή εφαρμογή γλώσσας assembly του παραπάνω κώδικα C++ και κάθε συνάρτηση αποτελείται μόνο από δύο οδηγίες κωδικού μηχανής καθεμία (συμπεριλαμβανομένης της υποχρεωτικής εντολής επιστροφής από την υπορουτίνα, μουσκεύω).

Για αναφορά, αυτό σημαίνει ότι ο χρόνος εκτέλεσης για κάθε συνάρτηση είναι της τάξης ενός μεμονωμένου κύκλου ρολογιού της CPU (!).

Αυτό είναι… Θέλω να πω… Ουάου!

Αλλά πως?

(Σημείωση: Τα ορίσματα συνάρτησης διαβιβάζονται στους καταχωρητές r1, r2, … και η τιμή του αποτελέσματος επιστρέφεται στον καταχωρητή r1. Για πιο λεπτομερείς πληροφορίες μη διστάσετε να διαβάσετε το Εγχειρίδιο MRISC32 Instruction Set.)

Εξυπνάδα μεταγλωττιστή C++

Το μεγαλύτερο μέρος της μαγείας προέρχεται από όλα τα έξυπνα κόλπα που έχουν ενσωματωθεί στη στοίβα μεταγλωττιστών GCC όλα αυτά τα χρόνια. Ακόμη και το αρκετά απλό και ανώριμο πίσω άκρο MRISC32 („περιγραφή μηχανής“ με όρους GCC) επωφελείται από τα περισσότερα κόλπα βελτιστοποίησης και μετασχηματισμού κώδικα για τα οποία γνωρίζει ο μεταγλωττιστής.

Για παράδειγμα:

  • ο raw_cast() Η λειτουργία προτύπου, που έχει εσωτερική σύνδεση, θα είναι ενσωματωμένη και δεν θα δημιουργηθεί ξεχωριστός κώδικας για αυτήν.
  • Ο μεταγλωττιστής αναγνωρίζει την κλήση προς memcpy(), και δεδομένου ότι το όρισμα του μήκους αντιγράφου είναι σταθερό, ο μεταγλωττιστής αντικαθιστά την κλήση με μια βελτιστοποιημένη ακολουθία κώδικα. Σε αυτή την περίπτωση θα είναι απλώς μια απλή λειτουργία μετακίνησης καταχωρητή – η οποία για το MRISC32, με το συνδυασμένο αρχείο καταχωρητή ακεραίων και κινητής υποδιαστολής, θα είναι στην πραγματικότητα εξαλειφθεί τελείως! Απαιτούνται μηδενικές οδηγίες CPU!
  • Οι λειτουργίες mask-and-shift αναγνωρίζονται από τον μεταγλωττιστή ως υπάρχουσες πεδίο bit λειτουργίες και χρησιμοποιούνται κατάλληλες οδηγίες CPU, εάν υπάρχουν. Εφόσον το MRISC32 ISA έχει οδηγίες πεδίου bit (π.χ ebfu και ibf), χρησιμοποιούνται.

Όσον αφορά άλλες αρχιτεκτονικές, ο μεταγλωττιστής δημιουργεί καλό κώδικα για ARMv8 επίσης (αν και χρειάζεται μερικές επιπλέον κινήσεις μεταξύ καταχωρητών κινητής υποδιαστολής και ακεραίων), αλλά για CPU:s χωρίς οδηγίες πεδίου bit ο μεταγλωττιστής πρέπει να δημιουργήσει μεγαλύτερες ακολουθίες (π.χ. x86-64 και RISC-V). Παρεμπιπτόντως, το Clang παράγει παρόμοια αποτελέσματα.

Το πραγματικά προσεγμένο εδώ είναι ότι μπορείτε να επωφεληθείτε από όλα αυτά τα κόλπα βελτιστοποίησης, ανεξάρτητα από το αν είστε προγραμματιστής C++ ή εάν προσθέτετε ένα νέο back end στο GCC.

Κατανόηση των βελτιστοποιήσεων μεταγλωττιστή

Φυσικά, αυτό δεν είναι αντιπροσωπευτικό παράδειγμα κώδικα C++ γενικά. Είναι πολύ σκόπιμα και ειδικά κατασκευασμένο για να αποδείξει κάτι. Στην πραγματικότητα, με μερικές μικρές τροποποιήσεις ο μεταγλωττιστής μπορεί να παράγει μια τάξη μεγέθους περισσότερες οδηγίες κώδικα μηχανής (π.χ. εάν η συνάρτηση raw_cast() είχε τοποθετηθεί σε άλλη μονάδα μεταγλώττισης, ενεργοποιώντας την εξωτερική σύνδεση).

Αυτό που ήθελα να επισημάνω είναι ότι οι σύγχρονοι μεταγλωττιστές είναι πολύ καλοί στη βελτιστοποίηση του κώδικα, αν του παρέχετε πληροφορίες και κατασκευές με τις οποίες μπορεί να λειτουργήσει.

Παλιά θα έπρεπε συχνά να γράφετε τον κώδικα C/C++ με τρόπο που να μοιάζει πολύ με τον επιθυμητό κώδικα μηχανής, αν θέλατε καλή απόδοση, αφού ο μεταγλωττιστής θα έκανε μια πολύ πιο κυριολεκτική μετάφραση από C/C++ σε κώδικα μηχανής . Η συσκευή του Duff είναι ένα παράδειγμα αυτού (και αυτές τις μέρες πρέπει συνήθως μην χρησιμοποιείτε τέτοιες σαφείς κατασκευές).

Με έναν σύγχρονο μεταγλωττιστή, είναι σημαντικό να:

  • Κάντε όσο το δυνατόν περισσότερες πληροφορίες διαθέσιμες στον μεταγλωττιστή κατά το χρόνο μεταγλώττισης (και όχι κατά το χρόνο εκτέλεσης).
    • Βεβαιωθείτε ότι οι κρίσιμες λειτουργίες απόδοσης είναι ενσωματωμένες (οι κλήσεις λειτουργιών προσθέτουν σημαντικά στοιχεία πάνω από το κεφάλι).
    • Βεβαιωθείτε ότι οι τιμές και οι εκφράσεις μπορούν να αξιολογηθούν κατά το χρόνο μεταγλώττισης.
    • Βάλτε σύντομους ορισμούς συναρτήσεων στα αρχεία συμπερίληψης.
    • Προτιμήστε τοπική σύνδεση (π.χ. χρησιμοποιήστε ανώνυμους χώρους ονομάτων).
    • Και τα λοιπά.
  • Μάθετε τα δυνατά σημεία και τους περιορισμούς της αρχιτεκτονικής στόχου σας.
  • Εξετάστε την έξοδο του μεταγλωττιστή για να δείτε αν έχετε αυτό που θέλετε.
  • Ως συνήθως, κάντε συγκριτική αξιολόγηση και προφίλ του κώδικά σας για να εντοπίσετε σημεία συμφόρησης (perf top είναι ένα προσωπικό αγαπημένο).

Μερικές συμβουλές

Κάντε τη συνήθεια να επιθεωρείτε την έξοδο του μεταγλωττιστή κάθε τόσο, ειδικά για τα κρίσιμα σημεία απόδοσης του κώδικά σας.

Ίσως ο ευκολότερος τρόπος για να ελέγξετε τη δημιουργία κώδικα για μικρά αποσπάσματα κώδικα είναι να χρησιμοποιήσετε Εξερεύνηση μεταγλωττιστή. Το χρησιμοποιώ όλη την ώρα. Είναι ιδιαίτερα χρήσιμο εάν στοχεύετε πολλούς μεταγλωττιστές (GCC, Clang, MSVC,…) ή/και αρχιτεκτονικές CPU (x86, ARM, PowerPC, RISC-V,…).

Ένα άλλο πολύ χρήσιμο εργαλείο είναι objdump. Είναι χρήσιμο όταν θέλετε να επιθεωρήσετε τα δυαδικά αρχεία που μεταγλωττίζετε τοπικά. Συνήθως τρέχω:

objdump -drC executable-or-library-or-object-file | less

…και, στη συνέχεια, αναζητήστε το όνομα της συνάρτησης ενδιαφέροντος (αναζήτηση με „/regex” σε λιγότερο, όπου regex είναι το όνομα της συνάρτησης, για παράδειγμα).

Schreibe einen Kommentar