Η παράλληλη απόδοση σελίδων PDF στο Delphi καταλήγει σε έναν κανόνα: δώστε σε κάθε νήμα εργασίας (worker thread) τον δικό του αποδότη (renderer). Η losLab PDF Library εκθέτει το RenderPagesToFilesParallel ακριβώς γι' αυτήν την εργασία, διαμοιράζοντας ένα εύρος σελίδων σε μια δεξαμενή TTask με μία παρουσία (instance) TPDFlib ανά εργάτη, οπότε ένα πολυπύρηνο μηχάνημα μετατρέπει μια μαζική εργασία εικονοποίησης (rasterization) σε ρυθμοαπόδοση που κλιμακώνεται κοντά στον αριθμό των πυρήνων. Αντιθέτως, αν μοιραστείτε μια μεμονωμένη παρουσία σε διάφορα νήματα, η εκτέλεση δεν θα επιβραδυνθεί ομαλά, αλλά θα καταστρέψει τη μνήμη και θα καταρρεύσει
Αυτό είναι το άρθρο στο οποίο ανατρέχετε όταν μια νυχτερινή εργασία πρέπει να μετατρέψει ένα PDF 500 σελίδων σε 500 PNG, το μηχάνημα διαθέτει 16 πυρήνες που κάθονται ανενεργοί, και η πρώτη σας ειλικρινής προσπάθεια να το εκτελέσετε με νήματα απέτυχε μέσα στο GDI+. Η σύντομη εκδοχή είναι ότι η ασφάλεια νημάτων εδώ είναι μια δομική ιδιότητα και όχι μια σημαία που ορίζετε, και το υπόλοιπο αυτού του άρθρου εξηγεί γιατί το ασφαλές σχήμα μοιάζει με αυτό που είναι και πού βρίσκεται το πραγματικό όριο επιτάχυνσης
Είναι η TPDFlib ασφαλής για νήματα (thread-safe) για παράλληλη απόδοση;
Όχι, και ο λόγος αξίζει να γίνει κατανοητός προτού σχεδιάσετε γύρω από αυτόν. Μια μεμονωμένη παρουσία TPDFlib έχει δηλωθεί για χρήση σε ένα μόνο νήμα, και το κρίσιμο σημείο είναι το TPDFPageTree.GetPage: γράφει ένα κοινόχρηστο πεδίο FPagePointer στην παρουσία ως παρενέργεια της επιλογής μιας σελίδας. Δύο νήματα που καλούν την ίδια παρουσία ανταγωνίζονται (race) για αυτό το πεδίο, οπότε ο εργάτης Α μπορεί να βρίσκεται στα μισά της σελίδας 3 όταν ο εργάτης Β επανακατευθύνει το δέντρο σελίδων στη σελίδα 40. Τίποτα στο API δεν σας εμποδίζει να γράψετε τον παρακάτω κώδικα, και θα εκτελεστεί ακόμη και για μερικές σελίδες προτού παρουσιάσει σφάλμα, που είναι ο χειρότερος τρόπος συμπεριφοράς για ένα τέτοιο σφάλμα
// ΜΗΝ το κάνετε αυτό: μία κοινόχρηστη παρουσία, πολλά νήματα
var
Pdf: TPDFlib;
begin
Pdf := TPDFlib.Create;
Pdf.LoadFromFile('report.pdf', '');
TParallel.For(1, Pdf.PageCount,
procedure(Page: Integer)
begin
// κάθε νήμα εισέρχεται ξανά στην ίδια παρουσία -> data race στο FPagePointer
Pdf.RenderPageToFile(150, Page, 0, 'page' + IntToStr(Page) + '.png');
end);
Pdf.Free;
end;
Η αποτυχία δεν είναι ντετερμινιστική, και αυτός είναι ακριβώς ο λόγος για τον οποίο επιβιώνει από μια γρήγορη δοκιμή και στη συνέχεια εμφανίζεται στο μηχάνημα ενός πελάτη με διαφορετικό αριθμό πυρήνων και βαρύτερο έγγραφο. Δεν υπάρχει επίσης κανένα κλείδωμα (lock) που να μπορείτε να τυλίξετε γύρω από το RenderPageToFile για να το διορθώσετε φθηνά, επειδή η διατήρηση ενός mutex σε ολόκληρη την κλήση απόδοσης σειριοποιεί την εργασία και πετάει τον παραλληλισμό που επιδιώξατε
Γιατί κάθε εργάτης απόδοσης χρειάζεται τη δική του παρουσία TPDFlib;
Επειδή η παρουσία είναι η μονάδα απομόνωσης. Μόλις κάθε εργάτης αποκτήσει μια ιδιωτική TPDFlib που φόρτωσε το αρχείο ανεξάρτητα, ο καθένας έχει το δικό του δέντρο σελίδων, το δικό του FPagePointer και τη δική του κατάσταση απόδοσης, οπότε δεν υπάρχει τίποτα κοινόχρηστο για να προκληθεί data race. Αυτή η ασφάλεια έχει ένα τίμημα που πρέπει να υπολογίσετε εκ των προτέρων: κάθε εργάτης αναλύει (parse) ολόκληρο το έγγραφο στη μνήμη, οπότε το μέγιστο αποτύπωμα μνήμης είναι περίπου N φορές το κόστος της μεμονωμένης παρουσίας. Οκτώ εργάτες σε ένα PDF 300 MB σημαίνουν οκτώ πλήρεις αναλύσεις στη μνήμη ταυτόχρονα, και σε πολύ μεγάλες εισόδους αυτός είναι ο περιορισμός που καθορίζει τον αριθμό των εργατών σας, και όχι η CPU. Όταν το έγγραφο είναι τεράστιο και περιορίζεστε από τη μνήμη παρά από τη CPU, η διαδρομή άμεσης πρόσβασης στο επεξεργασία μεγάλων PDF χωρίς ανάλυση ολόκληρου του εγγράφου είναι συχνά καλύτερη επιλογή από τα περισσότερα νήματα απόδοσης
Το API μίας κλήσης: RenderPagesToFilesParallel
Η losLab PDF Library πακετάρει ολόκληρο το ασφαλές πρότυπο πίσω από μια ενιαία μέθοδο, οπότε για τη συνήθη περίπτωση δεν χρειάζεται να υλοποιήσετε τίποτα χειροκίνητα. Το RenderPagesToFilesParallel λαμβάνει το όνομα αρχείου και τον κωδικό πρόσβασης, ένα DPI, μια συμπεριληπτική σελίδα έναρξης και λήξης, μια τιμή Options που περνά απευθείας στη διαδρομή εικονοποίησης ανά σελίδα, ένα μοτίβο εξόδου όπου το %p αντικαθίσταται από τον αριθμό σελίδας, και ένα όριο εργατών όπου οποιαδήποτε τιμή ίση ή μικρότερη από το μηδέν σημαίνει αυτόματη επιλογή. Επιστρέφει τον αριθμό των σελίδων που αποδόθηκαν με επιτυχία, και είναι μια διαδρομή μόνο για Windows επειδή βασίζεται στο CoInitialize και το GDI+
var
Pdf: TPDFlib;
Rendered: Integer;
begin
Pdf := TPDFlib.Create;
try
// FileName, Password, DPI, StartPage, EndPage, Options, Pattern, MaxWorkers
Rendered := Pdf.RenderPagesToFilesParallel(
'report.pdf', '', 150.0, 1, 500, 0, 'out\page_%p.png', 0);
// MaxWorkers = 0 -> auto: min(page count, CPU cores)
WriteLn(Format('%d pages rendered', [Rendered]));
finally
Pdf.Free;
end;
end;
Γιατί CoInitialize σε κάθε νήμα εργασίας;
Το GDI+ είναι ο εικονοποιητής (rasterizer) κάτω από την απόδοση σελίδας, και το GDI+ είναι apartment-threaded: αναμένει ότι το COM θα αρχικοποιηθεί σε οποιοδήποτε νήμα καλεί σε αυτό. Το κύριο νήμα μιας εφαρμογής VCL συνήθως το έχει ήδη ρυθμίσει αυτό, αλλά ένας φρεσκοδημιουργημένος εργάτης TTask όχι, και η κλήση της διαδρομής απόδοσης από ένα μη αρχικοποιημένο νήμα είναι ένας σίγουρος τρόπος για κατάρρευση. Έτσι, κάθε εργάτης συνδυάζει ένα CoInitialize(nil) κατά την είσοδο με ένα CoUninitialize κατά την έξοδο, οριοθετώντας ολόκληρη τη διάρκεια ζωής του. Αυτή είναι η ίδια πειθαρχία που χρειάζεται οποιαδήποτε εργασία GDI+ ή COM εκτός του κύριου νήματος, και είναι το δεύτερο μισό αυτού που κάνει την απομόνωση ανά εργάτη να ισχύει, με το πρώτο μισό να είναι η ιδιωτική παρουσία. Η ίδια διαδρομή εικονοποίησης GDI+ οδηγεί τις μηχανές μονού νήματος που καλύπτονται στο επιλογή μηχανής απόδοσης για έξοδο PDF
Στατικός καταμερισμός (Static sharding) έναντι δυναμικής διεκδίκησης σελίδων
Ο προφανής τρόπος για να μοιράσετε 500 σελίδες σε 8 εργάτες είναι να δώσετε στον καθέναν ένα σταθερό κομμάτι περίπου 62 σελίδων. Η losLab PDF Library δεν το κάνει αυτό, και ο λόγος είναι η εξισορρόπηση φορτίου (load balance). Το κόστος της σελίδας ποικίλλει άγρια: μια σελίδα κειμένου αποδίδεται σε χιλιοστά του δευτερολέπτου, ενώ μια σελίδα πυκνών διανυσματικών χαρτών ή μια σαρωμένη εικόνα πλήρους εκτύπωσης μπορεί να διαρκέσει πενήντα φορές περισσότερο. Χωρίστε την εργασία σε σταθερά κομμάτια (shards) και ο εργάτης που τυχαίνει να σχεδιάσει το βαρύ κομμάτι θα εκτελείται πολύ αφότου οι άλλοι έχουν μείνουν ανενεργοί, οπότε ο χρόνος σας καθορίζεται από το πιο άτυχο κομμάτι, και όχι από τον μέσο όρο. Αντίθετα, κάθε εργάτης διεκδικεί την επόμενη σελίδα από έναν κοινόχρηστο μετρητή κάτω από ένα σύντομο κρίσιμο τμήμα (critical section), την αποδίδει και επιστρέφει για την επόμενη, κάτι που κρατά κάθε πυρήνα απασχολημένο μέχρι να εξαντληθεί ολόκληρο το εύρος
// Τι κάνει κάθε εργάτης μέσα στη δεξαμενή (απλοποιημένο)
NextPage := StartPage;
IdxLock := TCriticalSection.Create;
WorkerProc :=
procedure
var
LocalLib: TPDFlib;
PageNum: Integer;
begin
CoInitialize(nil); // το GDI+ είναι apartment-threaded
try
LocalLib := TPDFlib.Create; // μία ιδιωτική παρουσία ανά εργάτη
try
LocalLib.LoadFromFile(FileName, '');
while True do
begin
IdxLock.Enter; // διεκδίκηση της επόμενης σελίδας ατομικά
try
PageNum := NextPage;
Inc(NextPage);
finally
IdxLock.Leave;
end;
if PageNum > EndPage then Break;
LocalLib.RenderPageToFile(DPI, PageNum, 0,
Format('page_%d.png', [PageNum]));
end;
finally
LocalLib.Free;
end;
finally
CoUninitialize;
end;
end;
Δομημένη καταγραφή (Structured logging) σε όλα τα νήματα εργασίας
Ο εντοπισμός σφαλμάτων σε μια μαζική εργασία που πεθαίνει στη σελίδα 213 από 500 είναι βασανιστικός χωρίς καταγραφή (log), και μια απλοϊκή καταγραφή αποτελεί από μόνη της σφάλμα ταυτόχρονης εκτέλεσης (concurrency bug). Η losLab PDF Library παρέχει το TPDFlibLogger, το οποίο συνδέεται μέσω της ιδιότητας TPDFlib.Logger και είναι nil από προεπιλογή, ώστε η διαδρομή χωρίς καταγραφέα να παραμένει μηδενικού κόστους. Λειτουργεί με callback-first: ορίζετε το OnLog και δρομολογείτε τις εγγραφές όπου επιθυμεί ο κεντρικός υπολογιστής, φιλτραρισμένες κατά επίπεδο llDebug / llInfo / llWarn / llError, και το PDFlibErrorMessage μετατρέπει τους ακατέργαστους αριθμητικούς κώδικες σε ανθρωπίνως αναγνώσιμο κείμενο, έτσι ώστε μια εγγραφή Error να διαβάζεται ως κάτι περισσότερο από έναν απλό ακέραιο. Ο προαιρετικός προορισμός αρχείου (file sink) είναι ο μόνος κοινόχρηστος πόρος, και προστατεύεται από ένα TCriticalSection ακριβώς ώστε πολλοί εργάτες να μπορούν να προσθέτουν στο ίδιο αρχείο καταγραφής με ασφάλεια. Σημειώστε το ειλικρινές όριο: μόνο αυτός ο προορισμός αρχείου είναι συγχρονισμένος, οπότε αν μοιράζεστε έναν καταγραφέα σε μια χειροκίνητη δεξαμενή και το OnLog αγγίζει το UI, πρέπει να το μεταφέρετε (marshal) πίσω στο κύριο νήμα μόνοι σας
var
Pdf: TPDFlib;
Log: TPDFlibLogger;
begin
Log := TPDFlibLogger.Create;
Log.Level := llInfo; // llDebug, llInfo, llWarn, llError
Log.FileName := 'render.log'; // optional shared sink (lock-guarded)
Log.OnLog :=
procedure(Level: TPDFlibLogLevel; Code: Integer; const Msg: WideString)
begin
if Level = llError then
// μεταφέρετε (marshal) στο νήμα του UI μόνοι σας. το OnLog ενεργοποιείται σε νήματα εργατών
WriteLn(Format('[%d] %s', [Code, PDFlibErrorMessage(Code)]));
end;
Pdf := TPDFlib.Create;
Pdf.Logger := Log; // nil από προεπιλογή. μηδενικού κόστους όταν δεν έχει οριστεί
try
Pdf.RenderPagesToFilesParallel('report.pdf', '', 150.0, 1, 500, 0,
'out\page_%p.png', 0);
// ένα Error τώρα φέρει κείμενο, π.χ. 401 -> "Wrong password or permission denied"
finally
Pdf.Free;
Log.Free;
end;
end;
Πόση επιτάχυνση πρέπει πραγματικά να αναμένετε;
Να είστε ειλικρινείς με τον εαυτό σας σχετικά με το πού πηγαίνει ο χρόνος, επειδή η παράλληλη απόδοση αποδίδει μόνο εκεί όπου η εργασία περιορίζεται πραγματικά από τη CPU (CPU-bound). Η έξοδος υψηλού DPI και οι πολύπλοκες διανυσματικές ή σκιασμένες σελίδες είναι βαριές για υπολογισμό, και αυτές κλιμακώνονται σχεδόν γραμμικά με τον αριθμό των πυρήνων μέχρι να κορεστεί η CPU. Οι απλές σελίδες είναι μια διαφορετική ιστορία: εκεί η επιβάρυνση του LoadFromFile ανά εργάτη, συν το κόστος του δίσκου για τη συγγραφή των αρχείων εξόδου, μπορεί να κατακλύσει την ίδια την απόδοση, και οκτώ εργάτες που καταπονούν έναν αργό δίσκο μπορούν να τελειώσουν πιο αργά από έναν καθαρό σειριακό βρόχο. Ορίστε το MaxWorkers στον φυσικό αριθμό των πυρήνων σας αντί για κάτι φιλόδοξο, προσέξτε τη μνήμη όταν το PDF προέλευσης είναι μεγάλο, και αν μια μαζική εργασία αποδειχθεί ότι περιορίζεται από το IO (IO-bound), η λύση είναι ταχύτερος αποθηκευτικός χώρος ή λιγότεροι εργάτες, και όχι περισσότερα νήματα. Χρησιμοποιούμενη στις εργασίες για τις οποίες κατασκευάστηκε, η διαδρομή μαζικής απόδοσης που εμφανίζεται εδώ αποτελεί μέρος της τυπικής losLab PDF Library για Delphi και C++Builder, και μετατρέπει τους ανενεργούς πυρήνες σε έτοιμες σελίδες χωρίς καμία από τις παγίδες ασφάλειας νημάτων που διαφορετικά θα έπρεπε να επιλύσετε μόνοι σας