Τεχνικό Άρθρο

Παράλληλη ανάλυση XLSX στο Delphi: Η στενωπός του διαχειριστή μνήμης

Το HotXLS, η εγγενής βιβλιοθήκη Excel για Delphi και C++Builder, αναλύει φύλλα εργασίας XLSX σε πολλαπλά νήματα μέσω μιας φόρτωσης τριών φάσεων: το XML του φύλλου αποσυμπιέζεται σειριακά, αναλύεται παράλληλα, και μικρά τμήματα διαβάζονται σειριακά στη συνέχεια. Η πρώτη έκδοση αυτής της δυνατότητας κέρδισε μόνο 12-25%, επειδή το προεπιλεγμένο κλείδωμα του διαχειριστή μνήμης του Delphi σειριοποιούσε τα νήματα εργασίας. Η μείωση των δεσμεύσεων σωρού (heap allocations) από περίπου 20 σε 9,1 ανά κελί αύξησε την παράλληλη επιτάχυνση σε ×1,90 σε οκτώ νήματα. Αυτό το άρθρο περιγράφει τις μετρήσεις, τις λανθασμένες επιλογές και τις δύο διορθώσεις που πραγματικά λειτούργησαν

Πώς το HotXLS αναλύει φύλλα εργασίας XLSX παράλληλα;

Το HotXLS χωρίζει την Open σε τρεις φάσεις, και μόνο η μεσαία εκτελείται σε νήματα εργασίας (worker threads). Ο λόγος είναι το κοντέινερ zip: ένα αρχείο zip είναι μια κοινόχρηστη ροή εισόδου με μια μηχανή κατάστασης inflate, και αυτή η μηχανή κατάστασης δεν μπορεί να διαβαστεί από δύο νήματα ταυτόχρονα. Η κάλυψή της με κλείδωμα θα ήταν άσκοπη, επειδή το inflate είναι εγγενώς σειριακό ανά καταχώριση, οπότε ένα κλείδωμα απλώς θα αναπαρήγαγε τη σειριακή εκτέλεση με επιπλέον επιβάρυνση. Η Φάση Α επομένως αποσυμπιέζει το XML κάθε φύλλου εργασίας στο δικό του TMemoryStream ενώ παραμένει μονονηματική· στο αρχείο αναφοράς μας αυτό διήρκεσε περίπου 4 ms για οκτώ τμήματα φύλλων, οπότε δεν πλησιάζει καθόλου τη στενωπό. Η Φάση Β εκτελεί την ParseWorksheetXml για κάθε φύλλο σε μια δεξαμενή νημάτων εργασίας (worker pool), όπου βρίσκεται σχεδόν όλος ο χρόνος φόρτωσης. Η Φάση Γ επιστρέφει στο zip σειριακά για τα μικρά τμήματα: σχόλια, σχέδια, γραφήματα και πίνακες

Η ίδια η δεξαμενή νημάτων εργασίας είναι σκόπιμα απλή. Οι εργάτες τραβούν δείκτες εργασιών από έναν κοινόχρηστο μετρητή με την InterlockedIncrement, οπότε φύλλα άνισου μεγέθους εξισορροπούνται φυσικά χωρίς κανέναν χρονοπρογραμματιστή. Ο αριθμός των νημάτων είναι min(sheet count, CPU cores), η πρώτη εξαίρεση εργάτη καταγράφεται με την AcquireExceptionObject και εγείρεται εκ νέου στο κύριο νήμα μετά τη συνένωση (join), και ο διανομέας (dispatcher) υποβαθμίζεται σε έναν απλό σειριακό βρόχο όταν υπάρχουν μηδέν ή μία εργασίες. Δύο ιδιότητες στην TXLSXWorkbook ελέγχουν τη δυνατότητα: η ParallelParse ελέγχει τη δεξαμενή, και η ParallelParseThreads περιορίζει τον αριθμό των νημάτων, με το 0 να σημαίνει αυτόματο. Τα βιβλία εργασίας με πολλαπλά φύλλα είναι η μορφή που επωφελείται, συμπεριλαμβανομένου του τύπου που παράγετε με τη διπλότυπη δημιουργία ενός φύλλου εργασίας προτύπου δεκάδες φορές

var
  Book: TXLSXWorkbook;
begin
  Book := TXLSXWorkbook.Create;
  try
    Book.ParallelParse := True;      // enable the parallel worker pool
    Book.ParallelParseThreads := 0;  // 0 = auto: min(sheets, CPU cores)
    if Book.Open('quarterly-ledger.xlsx') <= 0 then
      raise Exception.Create('open failed');
    // ... read cells as usual; the workbook is fully materialized ...
  finally
    Book.Free;
  end;
end;

Γιατί η προσθήκη νημάτων καθιστά την ανάλυση XLSX πιο αργή στο Delphi;

Επειδή ο προεπιλεγμένος διαχειριστής μνήμης του Delphi προστατεύει τον σωρό (heap) του με ένα καθολικό κλείδωμα (global lock), και η ανάλυση φύλλων εργασίας είναι πυκνή σε δεσμεύσεις μνήμης: κελιά, Variants και WideStrings κατά εκατομμύρια. Κάθε νήμα εργασίας που αγγίζει τον σωρό μπαίνει στην ουρά για αυτό το κλείδωμα, οπότε νήματα που φαίνονται ανεξάρτητα στον πηγαίο κώδικα εκτελούνται σχεδόν ένα τη φορά στην πράξη. Το πρώτο μας benchmark το έκανε αυτό επώδυνα συγκεκριμένο. Σε ένα βιβλίο εργασίας 8 φύλλων με 5.000 γραμμές επί 4 στήλες ανά φύλλο, μετρούμενο σε έναν i5-11600K (6 πυρήνες, 12 νήματα) υπό Win64, η παράλληλη Open βελτιώθηκε κατά μόνο 12-25% έναντι μιας εκτίμησης σχεδίου για τουλάχιστον 40%. Μια σάρωση του αριθμού νημάτων σε 2, 3, 4, 6 και 8 νήματα παρήγαγε μια επίπεδη καμπύλη, και σε μεταγενέστερες δοκιμές η διαμόρφωση 2 νημάτων ήταν στην πραγματικότητα 26% πιο αργή από τη σειριακή, η κλασική υπογραφή δύο νημάτων που ανταλλάσσουν ένα κλείδωμα με έντονο ανταγωνισμό (contended lock)

Τρεις μετρήσεις επιβεβαίωσαν τη διάγνωση, και καθεμία ανέτρεψε την προηγούμενη διαίσθηση. Πρώτον, ένα μικροσκοπικό αρχείο (8 φύλλα της 1 γραμμής) άνοιξε σε 1,2 ms, αποδεικνύοντας ότι η ανάλυση είναι ουσιαστικά το 100% της Open και δεν υπήρχε κρυφό σταθερό κόστος για να κατηγορηθεί. Δεύτερον, ένα microbenchmark καθαρής εναλλαγής δεσμεύσεων (allocation churn) έδειξε ότι ο διαχειριστής μνήμης του Delphi κλιμακώνεται αντίστροφα: ο ίδιος συνολικός όγκος 2 εκατομμυρίων δεσμεύσεων αντικειμένων και AnsiString έτρεχε 60% πιο αργά σε 8 νήματα από ό,τι σε ένα, ενώ η ίδια εναλλαγή έναντι του σωρού WideString, που είναι ο allocator COM BSTR αντί για τον Delphi MM, κλιμακώθηκε σε ×3,7. Το γεγονός ότι το HotXLS использует WideString παντού αποδείχθηκε ότι ήταν ένα ιστορικό ατύχημα που λειτούργησε υπέρ μας. Τρίτον, η GetProcessTimes έδειξε ότι κατά τη διάρκεια μιας παράλληλης Open, ο χρόνος CPU ήταν περίπου ίσος με τον πραγματικό χρόνο (wall time): οκτώ ονομαστικά νήματα κατανάλωναν CPU αξίας περίπου 1,3 νημάτων. Οι εργάτες δεν περιστρέφονταν (spinning)· κοιμούνταν στη διαδρομή ανταγωνισμού του διαχειριστή μνήμης, αποκλεισμένοι (blocked) παρά απασχολημένοι

Το πρακτικό μάθημα γενικεύεται πέρα από τα υπολογιστικά φύλλα. Εάν ένας φόρτος εργασίας στο Delphi εκτελεί πολλές δεσμεύσεις μνήμης, η αύξηση του αριθμού των νημάτων δεν κάνει τίποτα μέχρι να μειωθεί ο ρυθμός δεσμεύσεων, και μπορεί εύκολα να χειροτερέψει τα πράγματα. Πριν από αυτή τη διόρθωση, λέγαμε στους χρήστες που ρύθμιζαν την ParallelParseThreads την ειλικρινή αλήθεια: σε αρχεία που περιορίζονται από τις δεσμεύσεις μνήμης, τα περισσότερα νήματα δεν πρόσφεραν σχεδόν τίποτα

Από πού προέρχονται 20 δεσμεύσεις σωρού ανά κελί;

Ένας μετρητής-wrapper εγκατεστημένος με την SetMemoryManager απάντησε με ακρίβεια σε αυτήν την ερώτηση: περίπου 20 δεσμεύσεις Delphi-MM ανά κελί, με 2,87 εκατομμύρια από αυτές στα 32 bytes ή λιγότερο. Ο ένοχος δεν ήταν καθόλου τα αντικείμενα των κελιών. Η TXMLScaner.GetTokenValue δημιουργούσε μια νέα AnsiString σε κάθε κλήση, και καλείται περίπου 15-20 φορές ανά κελί: μία φορά για τα ονόματα στοιχείων, τα ονόματα ιδιοτήτων, τις τιμές ιδιοτήτων και το περιεχόμενο κειμένου. Επιπλέον, η διαδρομή UTF8ToWideString της RTL κατασκεύαζε μια προσωρινή ενδιάμεση UnicodeString για κάθε μετατροπή. Τα αντικείμενα των κελιών αντιπροσώπευαν μόνο 160 χιλιάδες δεσμεύσεις, περίπου το 8% του συνόλου, γεγονός που σκότωσε το αρχικό μας σχέδιο επί τόπου: είχαμε σκοπό να κατασκευάσουμε μια δεξαμενή αντικειμένων κελιών (cell object pool), και οι αριθμοί έδειξαν ότι δεν θα απέδιδε ποτέ

var
  OldMM, NewMM: TMemoryManagerEx;
  AllocCount, TinyCount: Int64;

function CountingGetMem(Size: NativeInt): Pointer;
begin
  AtomicIncrement(AllocCount);
  if Size <= 32 then
    AtomicIncrement(TinyCount);   // the small-object churn we care about
  Result := OldMM.GetMem(Size);
end;

// install before Open, restore afterwards
GetMemoryManager(OldMM);
NewMM := OldMM;
NewMM.GetMem := CountingGetMem;
SetMemoryManager(NewMM);

Αυτό το διαγνωστικό δέκα λεπτών αξίζει να το υιοθετήσετε για οποιαδήποτε έρευνα απόδοσης στο Delphi. Η καταμέτρηση των δεσμεύσεων ανά κατηγορία μεγέθους δεν κοστίζει σχεδόν τίποτα στην κατασκευή και σας λέει από πού προέρχεται πραγματικά η πίεση στον διαχειριστή μνήμης, η οποία στη δική μας περίπτωση ήταν δύο συνήθειες επιπέδου RTL μέσα στον σαρωτή XML (XML scanner) παρά οτιδήποτε στο μοντέλο αντικειμένων. Οι profilers συνέχιζαν να δείχνουν τον parser ως σύνολο· ο wrapper έδειξε δύο συγκεκριμένες γραμμές

Η διόρθωση: token interning και ένας αποκωδικοποιητής UTF-8 μηδενικού ενδιάμεσου

Δύο στοχευμένες αλλαγές στον αναγνώστη XML αφαίρεσαν περισσότερες από τις μισές δεσμεύσεις ανά κελί χωρίς να αγγίξουν τη δομή του parser. Η πρώτη είναι το interning των ονομάτων στοιχείων (element-name interning). Το XML του φύλλου εργασίας επαναλαμβάνει ένα μικροσκοπικό λεξιλόγιο ασταμάτητα: row, c, v, r, t, s και μερικά ονόματα ιδιοτήτων. Η InternTokenName διατηρεί μια κρυφή μνήμη (cache) 64 θέσεων για ονόματα που έχουν εμφανιστεί προηγουμένως και συγκρίνει τον buffer κατασκευής του σαρωτή με μια καταχωρισμένη εγγραφή χρησιμοποιώντας την TokenEqualsAnsi, μια άμεση σύγκριση byte που δεν δεσμεύει μνήμη. Σε περίπτωση επιτυχίας, επιστρέφει την αποθηκευμένη AnsiString, και εδώ η επιλογή τύπου έχει σημασία: η AnsiString έχει μέτρηση αναφορών (reference counted), επομένως η επιστροφή μιας αποθηκευμένης παρουσίας κοστίζει μία αύξηση της μέτρησης αναφορών και μηδενική κίνηση στον σωρό. Η WideString δεν έχει μέτρηση αναφορών, και κάθε ανάθεση περνά μέσα από την SysAllocString, οπότε το interning των WideStrings δεν θα εξοικονομούσε τίποτα. Το interning αξίζει να γίνεται μόνο στον τύπο συμβολοσειράς που υποστηρίζει μέτρηση αναφορών

function TXMLScaner.InternTokenName: AnsiString;
var
  Slot: Integer;
begin
  Slot := TokenHash mod 64;
  if TokenEqualsAnsi(FInternNames[Slot]) then
    Result := FInternNames[Slot]    // refcount++ only, no allocation
  else
  begin
    Result := GetTokenValue;        // materialize once, then cache
    FInternNames[Slot] := Result;
  end;
end;

Η δεύτερη αλλαγή στοχεύει στο κείμενο του κελιού. Η παλιά διαδρομή κατασκεύαζε ένα token AnsiString, το παρέδιδε στην UTF8ToWideString, η οποία κατασκεύαζε μια ενδιάμεση UnicodeString, η οποία τελικά μετατρεπόταν στη WideString που αποθηκεύει το κελί: δύο δεσμεύσεις Delphi-MM ανά token κειμένου πριν από την πραγματική. Η αντικατάσταση, η XmlUtf8ToWide(TokenPtr, TokenLen), είναι ένας αποκωδικοποιητής UTF-8 καθαρού Pascal δύο περασμάτων που διαβάζει απευθείας από τον buffer σάρωσης: το πρώτο πέρασμα μετρά το μήκος UTF-16, το δεύτερο πέρασμα αποκωδικοποιεί σε μια WideString που δεσμεύεται μία φορά. Καθαρό κόστος ανά token κειμένου: μία δέσμευση COM, μηδενικές δεσμεύσεις Delphi-MM. Μια σημασιολογική σημείωση για τους προσεκτικούς: σε κακοσχηματισμένες ακολουθίες UTF-8 ο νέος αποκωδικοποιητής περνά τα bytes ως έχουν αντί να τα αντικαθιστά με χαρακτήρες αντικατάστασης όπως κάνει η RTL, κάτι που επηρεάζει μόνο τον τρόπο υποβάθμισης των κατεστραμμένων αρχείων· σε έγκυρη είσοδο η έξοδος είναι byte-προς-byte πανομοιότυπη. Οι οντότητες χαρακτήρων XML δεν φτάνουν ποτέ στον αποκωδικοποιητή, επειδή ο σαρωτής τις έχει ήδη επιλύσει σε UTF-8 στον buffer των tokens

Τι κερδήθηκε, και πού η παράλληλη ανάλυση εξακολουθεί να μην βοηθά

Οι δύο διορθώσεις μείωσαν τις δεσμεύσεις ανά κελί από περίπου 20 σε 9,1, και τα παράλληλα νούμερα κινήθηκαν με τον τρόπο που προέβλεπε η θεωρία. Στο ίδιο benchmark 8 φύλλων και 5.000 γραμμών και στο ίδιο μηχάνημα 6C12T, η βελτίωση στα 8 νήματα πήγε από το 14% στο 47,4%, μια επιτάχυνση ×1,90 ένανσει της σειριακής. Η περίπτωση των 2 νημάτων μεταβλήθηκε από 26% πιο αργή σε 23,6% πιο γρήγορη, και η μετρούμενη χρήση της CPU αυξήθηκε από ×1,0 σε ×2,2. Η σειριακή διαδρομή έγινε περίπου 3% πιο γρήγορη ως πρόσθετο όφελος, καθώς οι λιγότερες δεσμεύσεις βοηθούν και ένα μεμονωμένο νήμα. Οι υπόλοιπες ~9 δεσμεύσεις ανά κελί είναι περίπου μισές αντικείμενα κελιών και μισές αποσβεσμένη ανάπτυξη κοντέινερ· τις μετρήσαμε, κρίναμε ότι οι αποδόσεις μειώνονται, και σταματήσαμε, με τον MM wrapper έτοιμο να ξανακάνει δειγματοληψία ανά σημείο κλήσης εάν ένας μελλοντικός φόρτος εργασίας δικαιολογεί έναν ακόμη γύρο

Τα όρια αξίζει να αναφερθούν τόσο ξεκάθαρα όσο και τα κέρδη. Το HotXLS παραλληλίζει σε επίπεδο φύλλου εργασίας (worksheet granularity), επομένως ένα βιβλίο εργασίας που είναι ένα γιγαντιαίο φύλλο αναλύεται σε ένα νήμα, ανεξάρτητα από το τι λέει η ParallelParseThreads· για αυτήν τη μορφή, ο αναγνώστης απευθείας ροής (streaming direct reader) είναι το καλύτερο εργαλείο, καθώς αποφεύγει εντελώς την υλοποίηση του βιβλίου εργασίας. Αρχεία των οποίων ο χρόνος αναλώνεται σε τμήματα της Φάσης Γ, όπως σχέδια, γραφήματα και σχόλια, βλέπουν μικρότερο όφελος επειδή αυτή η φάση παραμένει σειριακή από σχεδιασμό. Τα μικρά αρχεία δεν αξίζει καν να υποβάλλονται σε πολυνηματική επεξεργασία, γι' αυτό και ο διανομέας εκτελεί σιωπηρά σειριακή εκτέλεση για ασήμαντους αριθμούς εργασιών. Και το όριο του διαχειριστή μνήμης δεν έχει εξαφανιστεί, απλώς έχει υποχωρήσει: στις 9,1 δεσμεύσεις ανά κελί, το καθολικό κλείδωμα εξακολουθεί να επιβαρύνει τους εργάτες, γι' αυτό και τα οκτώ νήματα αποδίδουν ×1,90 αντί για ×4. Για το ευρύτερο σύνολο εργαλείων μείωσης των χρόνων φόρτωσης και αποθήκευσης, συμπεριλαμβανομένων των στυλ, των δεξαμενών και των bulk callbacks γραμμών, δείτε τον οδηγό μας για την απόδοση μεγάλων βιβλίων εργασίας στο Delphi

Η παράλληλη ανάλυση XLSX, οι ιδιότητες ParallelParse και ParallelParseThreads, και ο λιτός σε δεσμεύσεις μνήμης αναγνώστης XML που περιγράφεται εδώ αποστέλλονται ως τυπικά μέρη του HotXLS Delphi Excel Component, το οποίο διαβάζει και γράφει XLS, XLSX και ODS εγγενώς από το Delphi και το C++Builder χωρίς να εμπλέκεται αυτοματοποίηση του Excel