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

Βελτιστοποίηση της Απόδοσης (Performance) IO για Επεξεργασία (Processing) PDF Κλίμακας Gigabyte

Η πρώτη χρήσιμη ανάγνωση ενός αναλυτή 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 σχεδόν ποτέ δεν είναι γραμμικοποιημένα: η γραμμικοποίηση καταστρέφεται από τις ίδιες αυξητικές ενημερώσεις και συγχωνεύσεις που έκαναν το αρχείο μεγάλο. Σχεδιάστε για την εχθρική περίπτωση: μακρινά άλματα, καμία διάταξη, είσοδος από την ουρά πρώτα

PDF: Σχήμα πρόσβασης hop-by-hop επεξεργασίας PDF κλίμακας gigabyte, μπαίνοντας στο startxref στην ουρά αρχείου και μετά διατρέχοντας σκόρπιες offsets αναφορών διασταυρώσεων
Η πλοήγηση PDF μπαίνει από την ουρά και μετά χοροπηδά όπου δείχνει ο πίνακας cross-reference, κάτι που ηττά τη διαδοχική προ-ανάγνωση

Η ιστορία των 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

PDF: Κατακερματισμένος χώρος διευθύνσεων 32 bit που απορρίπτει MapViewOfFile ολόκληρου αρχείου, ενώ ένα τμήμα CreateFileMapping και ένα συρόμενο παράθυρο αντιστοίχισης 64 MB πετυχαίνουν
Ένα αντικείμενο ενότητας συν μία ζωντανή όψη κρατούν ένα PDF 1.8 GB αναγνώσιμο μέσα στον χώρο διευθύνσεων 2 GB μιας διεργασίας Delphi 32-bit

Πού ταιριάζει το 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

PDF: Στηλογράφημα 600000 κλήσεων ReadFile ανά αντικείμενο έναντι read-ahead συστάδων και 58 μεταβάσεων windowed-mapper κατά τη δεικτοδότηση PDF 1.8 GB που κρατά 300000 αντικείμενα
Οι αναγνώσεις ανά αντικείμενο καίνε 600,000 syscalls στο δοκιμαστικό αρχείο ενώ ο παραθυρισμένος mapper μειώνει την πλήρη σάρωση σε 58 μεταβάσεις