Η πρώτη χρήσιμη ανάγνωση ενός αναλυτή PDF βρίσκεται στη λάθος άκρη του αρχείου. Η μορφή τοποθετεί τον δείκτη startxref στα τελευταία bytes, οπότε η επεξεργασία ενός αρχείου 1.8 GB ξεκινά με μια μετάβαση στην ουρά, μια ανάγνωση ενός kilobyte, και έπειτα ένα άλμα όπου ο πίνακας διασταυρούμενων αναφορών λέει ότι βρίσκεται ο κατάλογος του εγγράφου. Από εκεί η ανάλυση είναι μια τυχαία διαδρομή σε όλο το εύρος των bytes. Ό,τι κάνει καλά η buffered IO — η διαδοχική προανάγνωση πίσω από τον δείκτη του αρχείου — στοχεύει σε έναν φόρτο εργασίας που το PDF δεν έχει
Η πρώτη έκδοση αυτού του άρθρου ισχυριζόταν ότι ένα αρχείο χαρτογραφημένο στη μνήμη λύνει την αποτυχία 32-bit λόγω εξάντλησης μνήμης που συναντά η TMemoryStream σε μια είσοδο 2 GB. Αυτός ο ισχυρισμός είναι λανθασμένος, και ο τρόπος με τον οποίο είναι λανθασμένος δείχνει την πραγματική διόρθωση: ένα ολισθαίνον παράθυρο χαρτογράφησης. Ακολουθεί το μοτίβο πρόσβασης, η διορθωμένη ιστορία των 32-bit με έναν λειτουργικό windowed mapper, και η αριθμητική των syscalls σε ένα αρχείο δοκιμής 1.8 GB με 300.000 αντικείμενα
Γιατί η διάταξη του PDF νικά τις buffered αναγνώσεις
Τρία δομικά γεγονότα διαμορφώνουν το μοτίβο IO. Πρώτον, η πλοήγηση καθοδηγείται από offset: ο πίνακας cross-reference χαρτογραφεί κάθε αριθμό αντικειμένου σε μια απόλυτη θέση byte, και τίποτα δεν απαιτεί αυτές οι θέσεις να είναι ταξινομημένες. Μετά από χρόνια αυξητικών ενημερώσεων, το αντικείμενο 4102 μπορεί να κάθεται στο offset 1.6 GB ενώ το αντικείμενο 4103 κάθεται στα 30 KB. Ένας βρόχος TFileStream μετατρέπει κάθε άντληση σε ένα Seek συν ένα Read, δύο μεταβάσεις πυρήνα, με ένα buffer που δεν συνεισφέρει τίποτα επειδή η επόμενη άντληση βρίσκεται εκατοντάδες megabytes μακριά
Δεύτερον, τα αντικείμενα ροής (ISO 32000-1 §7.5.7) πακετάρουν δεκάδες ή εκατοντάδες μικρά λεξικά σε έναν αποσυμπιεσμένο περιέκτη. Η άντληση ενός λεξικού σελίδας 300 bytes μπορεί να σημαίνει ανάγνωση και αποσυμπίεση μιας συστάδας 100 KB. Η άλλη όψη: τα αντικείμενα που γράφονται μαζί τείνουν να διαβάζονται μαζί, οπότε ένα buffer προσαρμοσμένο στη συστάδα εξυπηρετεί τις επόμενες δώδεκα αντλήσεις δωρεάν — η πιο εκμεταλλεύσιμη κανονικότητα στη μορφή
Τρίτον, η γραμμικοποίηση. Ένα γραμμικοποιημένο αρχείο βάζει στην αρχή την πρώτη σελίδα και έναν πίνακα υποδείξεων ώστε οι καταναλωτές να μπορούν να το διαβάσουν από την αρχή προς το τέλος. Τα αρχεία μεγέθους gigabyte σχεδόν ποτέ δεν είναι γραμμικοποιημένα: η γραμμικοποίηση καταστρέφεται από τις ίδιες αυξητικές ενημερώσεις και συγχωνεύσεις που έκαναν το αρχείο μεγάλο. Σχεδιάστε για την εχθρική περίπτωση: μακρινά άλματα, καμία διάταξη, είσοδος από την ουρά πρώτα
Η ιστορία των 32-bit, διορθωμένη
Μια διεργασία 32-bit των Windows έχει 2 GB χώρου διευθύνσεων χρήστη, και η MapViewOfFile με πλήθος byte ίσο με μηδέν ζητά μια συνεχόμενη δέσμευση ίση με το μέγεθος του αρχείου. Για μια είσοδο 2 GB αυτή η δέσμευση δεν μπορεί να επιτύχει: μετά το EXE, τα διάσπαρτα DLLs, και τις στοίβες νημάτων, το μεγαλύτερο ελεύθερο συνεχόμενο μπλοκ σε μια τυπική διεργασία Delphi 32-bit κάθεται κάπου ανάμεσα στα 700 MB και 1.4 GB. Η κλήση αποτυγχάνει με ERROR_NOT_ENOUGH_MEMORY, τον ίδιο τοίχο που χτυπά η TMemoryStream.LoadFromFile, απλώς μετατοπισμένο από τη δεσμευμένη RAM στη δέσμευση χώρου διευθύνσεων. Μια χαρτογράφηση πλήρους αρχείου δεν είναι λύση στα 32-bit, απλώς η ίδια αποτυχία πίσω από ονόματα API που ακούγονται καλύτερα
Η λύση είναι ο διαχωρισμός των δύο πραγμάτων που κάνει μια χαρτογράφηση. Η CreateFileMapping δημιουργεί το section object και δεν κοστίζει καθόλου χώρο διευθύνσεων, ανεξάρτητα από το μέγεθος του αρχείου. Μόνο η MapViewOfFile καταναλώνει χώρο διευθύνσεων, και τίποτα δεν την αναγκάζει να χαρτογραφήσει ολόκληρο το section: παίρνει ένα 64-bit αρχικό offset και ένα μήκος προβολής. Δημιουργήστε το section μία φορά, χαρτογραφήστε μια προβολή 64 έως 256 MB πάνω από την περιοχή που αναλύεται, αποχαρτογραφήστε πριν ολισθήσετε παρακάτω: το κόστος στον χώρο διευθύνσεων είναι ένα παράθυρο, όχι ένα αρχείο. Ένας περιορισμός: τα offsets των προβολών πρέπει να είναι πολλαπλάσια του SYSTEM_INFO.dwAllocationGranularity, 64 KB στην πράξη, οπότε ένα αίτημα για offset 1.000.000 στρογγυλοποιείται προς τα κάτω στο 983.040 και ο δείκτης του καλούντος προσαρμόζεται προς τα εμπρός κατά τη διαφορά
Ένας mapper με ολισθαίνον παράθυρο στο Delphi
Η παρακάτω κλάση τυλίγει ολόκληρη την πειθαρχία: ένα section object, μια ζωντανή προβολή, επανευθυγράμμιση κρυσταλλικότητας, και αναγνώσεις που διασχίζουν ένα όριο παραθύρου τις οποίες διαχειρίζεται μεγαλώνοντας αυτή τη μία προβολή αντί να ράβει δύο
uses
Winapi.Windows, System.SysUtils;
type
TWindowedFileMapper = class
private
FFile: THandle;
FMapping: THandle;
FFileSize: Int64;
FGranularity: DWORD; // SYSTEM_INFO.dwAllocationGranularity
FWindowSize: NativeUInt; // προεπιλεγμένο μέγεθος view
FViewBase: PByte; // βάση της τρέχουσας view (ευθυγραμμισμένη)
FViewOffset: Int64; // το file offset FViewBase αντιστοιχεί σε
FViewSize: NativeUInt; // bytes mapped στην τρέχουσα view
procedure Unmap;
public
constructor Create(const FileName: string;
WindowSize: NativeUInt = 64 * 1024 * 1024);
destructor Destroy; override;
function Map(Offset: Int64; Size: NativeUInt): PByte;
procedure ReadBytes(Offset: Int64; var Buffer; Count: NativeUInt);
property FileSize: Int64 read FFileSize;
end;
constructor TWindowedFileMapper.Create(const FileName: string;
WindowSize: NativeUInt);
var
Info: TSystemInfo;
begin
inherited Create;
FFile := CreateFile(PChar(FileName), GENERIC_READ, FILE_SHARE_READ, nil,
OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, 0);
if FFile = INVALID_HANDLE_VALUE then
RaiseLastOSError;
if not GetFileSizeEx(FFile, FFileSize) then
RaiseLastOSError;
// Το section object δεν δεσμεύει address space, ανεξάρτητα από το μέγεθος αρχείου
FMapping := CreateFileMapping(FFile, nil, PAGE_READONLY, 0, 0, nil);
if FMapping = 0 then
RaiseLastOSError;
GetSystemInfo(Info);
FGranularity := Info.dwAllocationGranularity; // 64 KB in practice
FWindowSize := WindowSize;
end;
destructor TWindowedFileMapper.Destroy;
begin
Unmap;
if FMapping <> 0 then CloseHandle(FMapping);
if FFile <> INVALID_HANDLE_VALUE then CloseHandle(FFile);
inherited;
end;
procedure TWindowedFileMapper.Unmap;
begin
if FViewBase <> nil then
begin
UnmapViewOfFile(FViewBase);
FViewBase := nil;
FViewSize := 0;
end;
end;
function TWindowedFileMapper.Map(Offset: Int64; Size: NativeUInt): PByte;
var
AlignedOffset: Int64;
Delta, MapSize: NativeUInt;
begin
if (Offset < 0) or (Offset + Int64(Size) > FFileSize) then
raise ERangeError.CreateFmt(
'Map request at %d for %d bytes is outside the file',
[Offset, Int64(Size)]);
// Fast path: το ζητούμενο range βρίσκεται ήδη μέσα στη live view
if (FViewBase <> nil) and (Offset >= FViewOffset) and
(Offset + Int64(Size) <= FViewOffset + Int64(FViewSize)) then
Exit(FViewBase + NativeInt(Offset - FViewOffset));
Unmap; // slide: μην κρατάτε ποτέ δύο views ταυτόχρονα
// Οι Views πρέπει να ξεκινούν σε όριο allocation-granularity
AlignedOffset := Offset - (Offset mod FGranularity);
Delta := NativeUInt(Offset - AlignedOffset);
MapSize := FWindowSize;
if MapSize < Size + Delta then // το request διασχίζει το τέλος του window
MapSize := Size + Delta; // μεγαλώστε αυτή τη view ώστε να το καλύψει
if AlignedOffset + Int64(MapSize) > FFileSize then
MapSize := NativeUInt(FFileSize - AlignedOffset); // κάντε clamp στο EOF
FViewBase := MapViewOfFile(FMapping, FILE_MAP_READ,
DWORD(AlignedOffset shr 32), DWORD(AlignedOffset and $FFFFFFFF),
MapSize);
if FViewBase = nil then
RaiseLastOSError;
FViewOffset := AlignedOffset;
FViewSize := MapSize;
Result := FViewBase + NativeInt(Delta);
end;
procedure TWindowedFileMapper.ReadBytes(Offset: Int64; var Buffer;
Count: NativeUInt);
begin
Move(Map(Offset, Count)^, Buffer, Count);
end;
Δύο λεπτομέρειες σηκώνουν το βάρος. Η γρήγορη διαδρομή στην κορυφή της Map επιστρέφει έναν δείκτη χωρίς καμία μετάβαση πυρήνα όταν το αιτούμενο εύρος βρίσκεται ήδη μέσα στη ζωντανή προβολή· χάρη στη συσταδοποίηση των object-stream αυτή είναι η συνήθης περίπτωση και από εκεί προέρχεται η εξοικονόμηση. Και ένα αίτημα που διασχίζει το τέλος του προεπιλεγμένου παραθύρου μεγαλώνει το MapSize για αυτή τη μία προβολή αντί να ράβει δύο, πράγμα που κρατά την ReadBytes μία γραμμή και τους καλούντες απαλλαγμένους από βρόχους μερικής ανάγνωσης
Το μέγεθος του παραθύρου είναι ένα ελαστικό κουμπί: στα 64 MB μια πλήρης σάρωση ενός αρχείου 1.8 GB είναι 29 προβολές, στα 256 MB είναι 8 αλλά κάθε δέσμευση είναι πιο δύσκολο να τοποθετηθεί σε έναν κατακερματισμένο χώρο 32-bit, και κάτω από περίπου 16 MB τα αρχεία με βαριά άλματα αναχαρτογραφούνται αρκετά συχνά ώστε να γίνεται αισθητό. Οπουδήποτε στο εύρος 64 έως 256 MB, η κίνηση χαρτογράφησης είναι στατιστικός θόρυβος
Μετρώντας τα syscalls
Τώρα η αριθμητική. Αρχείο δοκιμής: 1.8 GB, 300.000 έμμεσα αντικείμενα με μέσο όρο περίπου 600 bytes ωφέλιμου φορτίου. Ένας αναλυτής ανά αντικείμενο αντλεί το καθένα με SetFilePointerEx συν μια ReadFile 4 KB: 600.000 μεταβάσεις πυρήνα. Μια κρυφά αποθηκευμένη κλήση συστήματος ανάγνωσης κάνει κύκλο σε περίπου 1.5 μs στο τρέχον υλικό x64, οπότε αυτό είναι 600.000 × 1.5 μs ≈ 0.9 δευτερόλεπτα καθαρού κόστους πυρήνα πριν την ανάλυση ενός μοναδικού byte — η καλύτερη περίπτωση με ζεστή μνήμη. Κρύο, κάθε άλμα είναι μια λειτουργία συσκευής: στην αποτελεσματική λανθάνουσα κατάσταση των ~20 μs των τυχαίων αναγνώσεων 4 KB από NVMe, 300.000 από αυτές κοστίζουν περίπου 6 δευτερόλεπτα χρόνου συσκευής· σε αποθήκευση κατηγορίας SATA, λεπτά
Οι αναγνώσεις κινούν επίσης τα λάθος δεδομένα: 300.000 × 4 KB ωθεί 1.2 GB μέσα από buffer χρήστη για να παραδώσει περίπου 180 MB ωφέλιμου φορτίου — εξαπλάσια ενίσχυση, με κάθε byte να αντιγράφεται από τον πυρήνα στον χρήστη
Ένα buffer προανάγνωσης με μέγεθος προσαρμοσμένο στις συστάδες object-stream είναι η πρώτη ειλικρινής βελτίωση: μία ανάγνωση 256 KB ανά συστάδα αντί για μία ανά αντικείμενο μειώνει τον αριθμό των μεταβάσεων κατά μία έως δύο τάξεις μεγέθους. Είναι επίσης το σωστό εργαλείο όταν η χαρτογράφηση είναι άβολη, συνήθως στα δικτυακά μερίδια
Ο mapper με τα παράθυρα πηγαίνει πιο πέρα. Μια πλήρης σάρωση είναι 29 κλήσεις της MapViewOfFile και 29 της UnmapViewOfFile, 58 ρητές μεταβάσεις έναντι 600.000. Μια πραγματική ανάλυση καθοδηγούμενη από το xref δεν είναι μια καθαρή σάρωση, αλλά η γρήγορη διαδρομή απορροφά κάθε άντληση μέσα στο ζωντανό παράθυρο· ένα πάσο δημιουργίας ευρετηρίου μεταδεδομένων πάνω από το αρχείο δοκιμής κατέληξε σε μερικές εκατοντάδες αναχαρτογραφήσεις. Η χαρτογράφηση δεν αφαιρεί εργασία από τον πυρήνα: μετατρέπει ρητές κλήσεις συστήματος σε σφάλματα σελίδας που ο διαχειριστής μνήμης επιλύει σε συστάδες πολλών σελίδων, απευθείας από την κρυφή μνήμη του αρχείου χωρίς αντίγραφο στον χώρο του χρήστη, και οι περιοχές που δεν αγγίχτηκαν ποτέ δεν κοστίζουν τίποτα. Από την αρχή μέχρι το τέλος, το πάσο δημιουργίας ευρετηρίου πήγε από τα 23 δευτερόλεπτα κρύο και 7.1 δευτερόλεπτα ζεστό με αναγνώσεις ανά αντικείμενο στα 6.5 δευτερόλεπτα κρύο και 1.9 δευτερόλεπτα ζεστό με τον mapper· αυτό που παραμένει είναι το zlib inflate, όχι το IO
Πού ταιριάζει το FILE_FLAG_NO_BUFFERING
Το FILE_FLAG_NO_BUFFERING παρακάμπτει την κρυφή μνήμη συστήματος με αντάλλαγμα σκληρούς κανόνες στοίχισης: τα offsets, τα μήκη, και οι διευθύνσεις των buffer πρέπει να είναι όλα στοιχισμένα σε τομέα. Κερδίζει την αξία του σε διαδοχικές εργασίες ενός περάσματος που διαφορετικά θα πλημμύριζαν την κρυφή μνήμη με bytes που κανείς δεν διαβάζει δύο φορές — μια παρτίδα επανασειριοποίησης που ξαναγράφει ολόκληρο το αρχείο, ή ένα πάσο γραμμικοποίησης πάνω από την ολοκληρωμένη έξοδο. Με 4 έως 8 MB στοιχισμένων buffer πλησιάζει το διαδοχικό εύρος ζώνης της συσκευής χωρίς να ρυπαίνει την κρυφή μνήμη
Είναι εντελώς λάθος για ανάλυση. Τυχαία άλματα xref μέσω ενός μη προσωρινά αποθηκευμένου handle μετατρέπουν κάθε άντληση λεξικού 300 bytes σε μια πλήρη φυσική ανάγνωση χωρίς κρυφή μνήμη να απορροφήσει τη δεύτερη επίσκεψη — και η ανάλυση PDF επανεπισκέπτεται συνεχώς περιοχές, επειδή διαφορετικές σελίδες αναλύονται στα ίδια αντικείμενα ροής. Unbuffered IO για τη διαδοχική επανεγγραφή, mapped ή cached IO για την τυχαία ανάλυση· η σημαία είναι ανά handle, επομένως μια γραμμή επεξεργασίας μπορεί να κρατήσει και τα δύο στο ίδιο αρχείο
64-bit, σετ εργασίας, και η πλευρά της εγγραφής
Σε μια έκδοση 64-bit η αντίρρηση του χώρου διευθύνσεων εξαφανίζεται: περάστε το μέγεθος του αρχείου ως παράθυρο και η παραπάνω κλάση υποβαθμίζεται σε μία μόνο πλήρη χαρτογράφηση. Η παγίδα σε υπηρεσίες μεγάλης διάρκειας: οι σελίδες μόνο για ανάγνωση που υποστηρίζονται από αρχείο δεν χρεώνουν καμία δέσμευση, επομένως οι μετρητές δέσμευσης παραμένουν ήρεμοι, αλλά κάθε σελίδα που αγγίζεται μπαίνει στο σύνολο εργασίας· αναλύστε το μεγαλύτερο μέρος από 1.8 GB και το σύνολο εργασίας μεγαλώνει για να ταιριάξει, διώχνοντας όλα τα υπόλοιπα. Τα οριοθετημένα παράθυρα βάζουν ταβάνι σε αυτό, οπότε το ολισθαίνον μοτίβο παραμένει η σωστή προεπιλογή ακόμα και όταν ο χώρος διευθύνσεων είναι ελεύθερος
Στην πλευρά της εγγραφής, το φθηνότερο IO είναι το IO που δεν εκδόθηκε ποτέ. Ο μηχανισμός αυξητικής ενημέρωσης του PDF (ISO 32000-1 §7.5.6) προσαρτά τα τροποποιημένα αντικείμενα και μια νέα ενότητα διασταυρούμενων αναφορών μετά τα αρχικά bytes, τα οποία δεν κινούνται ποτέ. Το σφράγισμα μιας σελίδας στο αρχείο 1.8 GB προσαρτά δεκάδες kilobytes· μια πλήρης επανεγγραφή μετακινεί όλα τα 1.8 GB, πέντε τάξεις μεγέθους μακριά, και η προσάρτηση είναι καθαρά διαδοχική έξοδος στην ουρά
Πού ταιριάζουν οι βιβλιοθήκες της losLab
Και οι δύο βιβλιοθήκες PDF της losLab αποστέλλουν αυτή την πειθαρχία ως API. Το HotPDF Direct File API διαβάζει τον αριθμό σελίδων και τη δομή μέσω μιας λαβής αρχείου χωρίς να χτίζει το δέντρο αντικειμένων, αντιγράφει και αποκρυπτογραφεί σε επίπεδο αρχείου, και γράφει τα deltas μέσω του BeginIncrementalUpdate — η στρατηγική μόνο για προσάρτηση, πακεταρισμένη. Το PDF Library for Delphi ακολουθεί την ίδια διαδρομή με το στρώμα Direct Access: ένας streaming reader που περπατά τον πίνακα διασταυρούμενων αναφορών στη θέση του, αντλεί τα αντικείμενα τεμπέλικα, εξάγει εύρη σελίδων από αρχείο σε αρχείο, και διατηρεί τις επεξεργασίες ως αυξητικές αναθεωρήσεις. Αν γράφετε τον δικό σας αναλυτή, η κλάση του mapper είναι δική σας για να την πάρετε· αν τρέχετε μια γραμμή επεξεργασίας εγγράφων, αφήστε τη βιβλιοθήκη να κρατήσει το παράθυρο ειλικρινές
Σημείωση: Ο βελτιστοποιημένος χειρισμός IO για έγγραφα κλίμακας gigabyte είναι ενσωματωμένος απευθείας στο HotPDF Delphi VCL Component για Delphi και C++Builder