Artykuł techniczny

Wydajność wyodrębniania stron w HotPDF dla Delphi

Dwie minuty na skopiowanie trzech stron z 40-stronicowego pliku PDF to nie jest problem optymalizacji wydajności. To sygnał, że używana jest niewłaściwa ścieżka API. Kiedy po raz pierwszy zobaczyłem ten czas w przykładzie kopiowania stron komponentu HotPDF Component, moim instynktem było najpierw spojrzenie na strukturę dokumentu, a dopiero potem na kod. Okazało się, że ta kolejność ma znaczenie

Co było w rzeczywistości wolne

Przedmiotowy plik PDF był 40-stronicowym dokumentem referencyjnym z nietrywialnym drzewem stron: wieloma pośrednimi węzłami /Pages zamiast jednej płaskiej tablicy. Oryginalny przykładowy kod wywoływał LoadFromFile, następnie budował nowy dokument za pomocą BeginDoc, w pętli przechodził przez wybrane numery stron i przy każdej iteracji ponownie ładował dokument źródłowy z dysku, aby pobrać stronę. Jest to koszt pełnego parsowania pomnożony przez liczbę stron, które chcemy wyodrębnić. Plik o rozmiarze 12 MB uderzał w dysk sześć razy dla ekstrakcji trzech stron, ponieważ nikt nie sprawdził, czy plik musi pozostać otwarty między iteracjami

Drugi czynnik był niewidoczny w kodzie: metoda LoadFromFile w HotPDF rozwiązuje całą tabelę odsyłaczy (cross-reference) i dekompresuje każdy strumień obiektów podczas ładowania. To właściwe zachowanie dla dokumentu, który zamierzasz modyfikować, ale to więcej pracy, niż potrzebujesz, jeśli chcesz tylko poznać liczbę stron i ich podzbiór. Dla dostępu do struktury tylko do odczytu, DAOpenFileReadOnly pozwala uniknąć deserializacji pełnego drzewa obiektów, co ma znaczenie w przypadku skompresowanych plików z dużymi zasobami obrazów

Żadne z powyższych nie jest błędem biblioteki. W obu przypadkach winni są wywołujący, którzy wybierają API zaprojektowane do jednego zadania i używają go do czegoś zupełnie innego

Użycie InsertPagesFromDocument do wyodrębniania stron

Właściwą drogą do kopiowania zakresu stron z jednego dokumentu HotPDF do drugiego jest InsertPagesFromDocument, wywoływane po LoadFromFile na pliku źródłowym. Ładujesz źródło raz, ładujesz lub tworzysz miejsce docelowe raz, przenosisz strony i zapisujesz. Źródło pozostaje w pamięci podczas wszystkich operacji wstawiania stron:

procedure ExtractPages(const SourceFile, DestFile: string;
  const PageRange: string);
var
  Source, Dest: THotPDF;
begin
  Source := THotPDF.Create(nil);
  Dest   := THotPDF.Create(nil);
  try
    // Load source once: full parse happens here and only here
    Source.LoadFromFile(SourceFile);

    // Build a minimal destination document
    Dest.FileName := DestFile;
    Dest.BeginDoc;

    // Copy the requested range; '1-3' inserts pages 1 through 3
    // starting at position 1 in the destination
    Dest.InsertPagesFromDocument(Source, PageRange, 1);

    Dest.EndDoc;
  finally
    Source.Free;
    Dest.Free;
  end;
end;

Parametr PageRange akceptuje taki sam format jak w przykładzie dla wiersza poleceń: oddzieloną przecinkami listę numerów stron lub zakresów, taką jak '1-3' lub '1,5,7-9'. Strony są indeksowane od 1. Metoda InsertPagesFromDocument kopiuje strumienie zawartości, słowniki zasobów i geometrię strony, bez modyfikowania metadanych, zakładek (bookmarks) ani osadzonych załączników plików, chyba że są do nich odwołania w skopiowanych stronach. Dla wyodrębnienia trzech stron z dokumentu liczącego 40 stron, to niewielki zbiór roboczy

Czas dla tego samego pliku o rozmiarze 12 MB, którego przetwarzanie zajmowało wcześniej dwie minuty: mniej niż 1,5 sekundy przy użyciu tego wzorca. Większość tego czasu zajmuje pojedyncze wywołanie LoadFromFile. Struktura dokumentu nie ma znaczenia po pierwszym rozwiązaniu tabeli obiektów

Kiedy LoadFromFile to za dużo: Direct File API

Jeśli potrzebujesz jedynie policzyć strony, sprawdzić informacje o dokumencie lub skopiować plik bez ingerencji w jego zawartość, API Direct File pozwala całkowicie uniknąć pełnego parsowania. DAOpenFileReadOnly mapuje tabelę odsyłaczy (cross-reference) bez dekompresji strumieni obiektów, dzięki czemu liczenie stron charakteryzuje się złożonością O(rozmiar xref) zamiast O(rozmiar pliku):

procedure InspectPDF(const FileName: string);
var
  Pdf: THotPDF;
  Handle, PageCount: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Handle := Pdf.DAOpenFileReadOnly(FileName, '');
    if Handle <= 0 then
      Exit;
    try
      PageCount := Pdf.DAGetPageCount(Handle);
      Writeln('Pages: ', PageCount);

      // DACopyFile is a byte-preserving copy, no re-serialization
      Pdf.DACopyFile(FileName, 'archive-copy.pdf');
    finally
      Pdf.DACloseFile(Handle);
    end;
  finally
    Pdf.Free;
  end;
end;

Zastrzeżenie: DAOpenFileReadOnly akceptuje parametr z hasłem, ale w przypadku szyfrowanych plików wejściowych ucieka się do pełnego parsowania, ponieważ deszyfrowanie wymaga drzewa obiektów do rozwiązania słownika szyfrowania. Jeśli twoje pliki źródłowe są zaszyfrowane, najpierw odszyfruj je za pomocą DecryptFile, aby uzyskać jawną kopię, a następnie otwórz ją za pomocą API Direct File. Funkcja na poziomie pliku DecryptFile obiera bezpośrednią ścieżkę przepisywania przez AES-256 w przypadku standardowego szyfrowania i w przypadku dużych plików jest znacznie szybsza niż LoadFromFile, po którym następuje wywołanie SaveLoadedDocument, ponieważ nie buduje pełnego modelu obiektowego w pamięci RAM

Pamięć podczas przetwarzania dużych wsadów (batch processing)

Zadania wsadowe (batch jobs), które przetwarzają dziesiątki plików w pętli, charakteryzują się wzorcem, który wygląda poprawnie, ale kumuluje pamięć: utworzenie obiektu THotPDF wewnątrz pętli, wywołanie LoadFromFile, wykonanie pracy i wywołanie Free. Strukturalnie jest to w porządku. Problem pojawia się, gdy wewnętrzna praca alokuje obiekty pomocnicze (scratch objects), przechwytuje wyjątki (exceptions) i pozostawia te obiekty przy życiu na ścieżkach błędu. Menedżer pamięci w Delphi nie wykonuje defragmentacji, więc sto wycieków na ścieżkach błędu podczas przetwarzania wsadowego może podnieść zużycie pamięci na tyle, by spowolnić alokację dla całej reszty programu

Rozwiązanie tego problemu nie jest egzotyczne. Każdy THotPDF oraz każdy pośredni TStream lub TBitmap, który uczestniczy w obróbce pliku PDF, należy do bloku try/finally, w którym instrukcja Free powinna znajdować się na samym końcu. Przed rozpoczęciem pętli blokowej try przypisuj lokalnym wskaźnikom wartość nil, tak aby gałąź sekcji finally mogła bez problemu (i w pełni bezpiecznie) korzystać po prostu ze standardowej komendy typu if Assigned(x) then x.Free w przypadku, gdy inicjalizacja napotka nieoczekiwany awaryjny problem jeszcze przed samym jej zwieńczeniem. Jest to wręcz w pełni standardowa dyscyplina własności (ownership discipline) po stronie środowiska Delphi, co po prostu absolutnie zamyka temat i całkowicie wyjaśnia obiektywne kłopoty w obrębie podanej wyżej klasy błędów

Jeszcze jedna rzecz, którą warto sprawdzić w kontekście przetwarzania wsadowego: metoda AddImage rejestruje obrazy na wewnętrznej liście, która pozostaje aktywna przez cały czas życia instancji obiektu THotPDF. W sytuacji częstego (powtarzalnego) odgórnego wywoływania LoadFromFile na poczet wtórnego wykorzystania tego samego bazowego uchwytu obiektu pod całkowicie liczne wywoływane w tle instancje innych rzędów obsługiwanych na poczet przetwarzania innych wariantów kolejnych bazowych zrzutów po stronie poszczególnych ułożonych u progu procesowania nowszych podyktowanych zrzuconych u podstaw rzeczonej listy zadań do odczytów i pracy w ramach innych nowych rzutów dokumentów - stare dane (i obrazy starych plików o których tu de facto mowa w rzeczonej puli rejestrowanych tu grafik i zdjęć) ze starszych plików na trwałe zalegają nieruszone właśnie aż w oparciu od wewnątrz samej bazy dla wskazanej tu strukturalnej wewn. bibliotecznej listy. Należy zatem de facto przy każdorazowym podjęciu rzędu nowego odczytu u nowego zrzuconego pod parser pliku stworzyć czystą instancję per podjęty dany plik docelowy bądź chociaż mianowicie zrzucić dyrektywą celowe żądane wyzerowanie ze zwolnieniem dla zrzutu samej pełnej ścieżki listy w podanym zrzucie owych podpiętych obrazów pod podany docelowy nowy wskazany wywoływany u docelowego interfejsu API zrzut zasobów po stronie każdego przetwarzanego pliku w locie

Mierzenie (pomiary) przed dokonaniem jakichkolwiek zmian

Zanim sięgniesz po którykolwiek z tych wzorców operacyjnych na binarnej ścieżce wywoławczej – u samego początku po prostu wszystko najpierw odpowiednio wymierz (u podstaw dla danej zrzucanej do przetworzenia po stronie pętli u docelowego testowanego algorytmu iteracji bazowej bazy pomiarowej). Delphi oferuje chociażby sam tzw. TStopwatch operujący za pośrednictwem zrzuconego w środowisku w System.Diagnostics narzuconego tam wprost systemowego u struktury licznika ujętego we frameworkowym nakazu tzw. pętli typu (czyli ujętego na poziomie klasy od Windows rygoru pod komendę operacyjną określanego tuż m.in. dla ujętego tu po stronie de facto rzędu QueryPerformanceCounter), który z powodzeniem gwarantuje bardzo wysoki szczebel samej w sobie oficjalnej precyzji podczas oficjalnego odczytu pomiarowego, jak chociażby przy powołanej na cel śledzenia samej fazy cyklu pobierania czasu rzędu realnego wywołań systemowych plikowego typu I/O (czyli zrzuconego u samych podstaw wywołania bazowego wejścia-wyjścia operacyjnego). Wyizoluj poprzez spięcie (wrap) klamrami pomiarowymi na początek tylko same jedno konkretne i docelowe wywoływanie dla wskaźnika metodycznej pętli operacyjnej u LoadFromFile po czym zwyczajnie przyjrzyj się jaka de facto objętościowa partia czasu zostanie w ogóle przywłaszczona jako zaangażowana. Jeśli to z kolei pochłonie np. u twojej estymacji chociażby zrzut rzędu aż powiedzmy góry docelowego odsetka liczącego na szali ok 90% czasu rzędu samego pełnego procesu całkowitego – poprawnie celujesz – bowiem ujęcie tu nakazów Direct File API bądź samo rzetelne oszczędzanie u źródła samej powtarzalności z którą to system jest natarczywie obarczany żądaniem przetwarzania w obiekcie u tego samego docelowo pliku to na pewno w istocie docelowa nakazana baza i podyktowana recepta zrzuconych kłopotów i celowy twój tzw. "fix". O ile jednak z kolei ten rygor pochłania co najwyżej czy powiedzmy nawet poniżej samych np. ok 20% całości u pułapu, wówczas rzeczonego twojego powołanego odgórnie tu de facto na żądanie u systemowych ram mitycznego dławiącego procesy operacyjne rzeczonego uprzednio pod sam zrzut systemowy w rzędzie systemowym jako wąskiego tak na poczet ogółu powierzonego gardła powinieneś dopatrywać się całkiem pod wprost zupełnie innym zadeklarowanym adresem, tym samym polując i to całkiem niepotrzebnie pod docelowo na poczet całości zupełnie i wręcz od podstaw całkiem tylko podyktowany niewłaściwy operacyjny wektor tropów u swoich celowych żądanych testowanych śledztw

Dwie minuty ujętej tu na wyodrębnienie czasu i pracy wywołań na początku rzeczonego wpisu okazały się zrzuconą tu de facto finalnie w samej wprost czystej objętości całościowym efektem zjawiska polegającego wyłącznie na tym powtarzającym u progu iterowanym na okrągło nakazanym cyklu ciągłego wgrywania do obiektu zasobów (repeated-load pattern). Z kolei sama wewnętrzna oficjalna budowa plików u samej dokumentacji odgórnie wcale finalnie do nakazanego procesu wręcz w niczym ostatecznie całkiem tu docelowo od progu samej strukturalnej objętości zupełnie de facto w ogóle nic absolutnie na bazy spowolnień dla testu nie rzutowała; pod pojęciem owego zrzuconego dokumentu tzw. ujęte tu "płaskie drzewo" operowałoby tak dla wskazanego powierzonego na szali wywołania wręcz i identycznie w samych ramach procedur dla operacji na ten sam i w ten rygor. Samo przestawienie procedur nakazanych do pożądanego i oficjalnego użycia u wywołania ledwie chociażby absolutnie pojedynczego oficjalnego wariantu dyrektyw rzędem z procedur wywołania jako stricte mianowicie zrzuconego dla wariantu operacyjnego ujętego tu po rzędzie rzeczonego tylko jako same de facto prostej formy np. LoadFromFile u samego progu, po którym już oficjalnie na cel obrano wykonanie u wywołania wyłącznie pojedynczego i stricte oficjalnego podpięcia użytej dla operacji formy polecenia typu InsertPagesFromDocument de facto oficjalnie odegrało tu diametralną różnicę pod wprost diametralnym odciążeniem sprzętowym dla samej bazy używanego na te poczet rzeczonego sprzętu przynosząc poprawę docelowo do poziomu góra zrzutu rzędu równego około 1,3 sekundy z rygorem nienaruszenia nawet innych pomniejszych powierzonych na zewnątrz jakichkolwiek rzuconych procedur pobocznych po za opisanym operacyjnym wzorcem procesów

Pokazane tu API do manipulacji stronami jest częścią komponentu HotPDF Component dla Delphi i C++Builder