Artykuł techniczny

HotPDF: scalanie zakresów przy zdalnych PDF w Delphi

HotPDF wczytuje plik PDF z dowolnego źródła o dostępie swobodnym, które sam zaimplementujesz, a THPDFCoalescingRandomAccessSource opakowuje to źródło tak, że rozproszone drobne odczyty analizatora zamieniają się w ograniczony zestaw buforowanych zakresów blokowych z asynchronicznym odczytem wyprzedzającym. Przy dokumencie serwowanym przez żądania zakresów HTTP to różnica między kilkuset podróżami w obie strony a kilkudziesięcioma

Nic w analizatorze się nie zmienia. Nadal wywołujesz LoadFromRandomAccessSource, wraca ten sam obiekt dokumentu, a to samo API stron działa. Zmienia się ruch pod spodem

Dlaczego ten sam plik PDF wczytuje się natychmiast lokalnie, a pełznie przez sieć?

Ponieważ analizator PDF nie czyta pliku, tylko po nim nawiguje. Przeskakuje na koniec w poszukiwaniu startxref, skacze z powrotem do tablicy odsyłaczy krzyżowych, rozwiązuje słownik trailer, podąża za odwołaniem do katalogu, potem do korzenia drzewa stron, potem do węzła strony, potem do jego słownika zasobów. Każdy z tych kroków czyta dziesiątki bajtów z innego przesunięcia

Na lokalnym pliku ten wzorzec jest niemal darmowy: system operacyjny już ma zbuforowaną otaczającą stronę 4 KiB, więc drugi odczyt kosztuje tyle co memcpy. Przez transport sieciowy nie ma takiej lokalności. Każdy odczyt to żądanie z własnym opóźnieniem, a 300 sekwencyjnych żądań po 40 ms każde to dwanaście sekund spędzonych niemal wyłącznie na czekaniu. Rozwiązaniem nie jest czytanie mniej; analizator potrzebuje dokładnie tego, o co prosi. Rozwiązaniem jest sprawienie, by każdy fizyczny odczyt obejmował więcej z tego, czego zażąda kolejny odczyt logiczny

Co zmienia scalanie

Źródło scalające zaokrągla każdy odczyt w górę do bloku i buforuje ten blok. BlockSize domyślnie wynosi 262 144 bajty, a MaxCacheBytes 2 097 152, więc domyślnie rezyduje osiem bloków i są one usuwane w kolejności najdawniej używanych względem twardego budżetu bajtowego. 40-bajtowy odczyt klucza trailer wykonywany przez analizator ściąga 256 KiB wokół niego, a kolejny tuzin odczytów w tym sąsiedztwie, gdzie mieszkają dane odsyłaczy krzyżowych i katalogu, jest obsługiwany z pamięci

Twoje własne źródło pozostaje proste. Zaimplementuj GetSize i ReadAt, nadpisz ReadAtCancellable, jeśli twój transport potrafi przerwać w locie, a resztę, czyli buforowanie, scalanie i odczyt wyprzedzający, zostaw opakowaniu

type
  THttpRangeSource = class(THPDFRandomAccessSource)
  private
    FClient: TMyHttpClient;
    FUrl: string;
    FSize: Int64;
  public
    function GetSize: Int64; override;
    function ReadAt(Offset: Int64; var Buffer; Count: Longint): Longint; override;
    function ReadAtCancellable(Offset: Int64; var Buffer; Count: Longint;
      CancellationToken: THPDFCancellationToken): Longint; override;
  end;

var
  Raw: THttpRangeSource;
  Cached: THPDFCoalescingRandomAccessSource;
  Pdf: THotPDF;
begin
  Raw := THttpRangeSource.Create('https://files.example.com/contract.pdf');
  // OwnsSource=True: opakowanie zwalnia Raw razem z sobą
  Cached := THPDFCoalescingRandomAccessSource.Create(Raw, True, 262144, 8388608);
  Pdf := THotPDF.Create(nil);
  try
    Cached.AsyncPrefetchEnabled := True;
    Cached.AdaptiveReadAheadEnabled := True;
    Cached.MaxReadAheadBlocks := 8;

    if Pdf.LoadFromRandomAccessSource(Cached, True) = 1 then
      RenderFirstPage(Pdf);
  finally
    Pdf.Free;
  end;
end;

Jak daleko powinien sięgać odczyt wyprzedzający?

Adaptacyjny odczyt wyprzedzający odpowiada na to pytanie osobno dla każdego dokumentu, zamiast zmuszać cię do zgadywania. Przy włączonym AdaptiveReadAheadEnabled okno rośnie przez 1, 2, 4 i 8 bloków w miarę kumulowania się utrzymujących się odczytów w przód i nigdy nie przekracza MaxReadAheadBlocks ani skonfigurowanej pojemności pamięci podręcznej. W chwili, gdy nadchodzi odczyt, który nie kończy się mniej więcej tam, gdzie skończył się poprzedni, okno zapada się, a prefetch zostaje wstrzymany

SequentialReadToleranceBytes, domyślnie 4096, definiuje to „mniej więcej”. Odczyty mieszczące się w tej odległości od końca poprzedniego odczytu wciąż liczą się jako sekwencyjne, co ma znaczenie, ponieważ analizator PDF przechodzący przez strumień treści nie generuje idealnie ciągłych przesunięć; pomija tu pole długości, tam wewnętrzny słownik. Ustaw tolerancję zbyt nisko, a normalne skanowanie w przód zostanie zaklasyfikowane jako losowe, więc odczyt wyprzedzający nigdy się nie włączy. Ustaw ją zbyt wysoko, a prawdziwy dostęp losowy wygląda na sekwencyjny, więc ściągasz megabajty, których nikt nie chce. Wartość domyślna jest skalibrowana pod przechodzenie przez strumień treści, a statystyki powiedzą ci, czy twój transport się z tym nie zgadza

Ta asymetria jest celowa: wzrost jest stopniowy, zapaść jest natychmiastowa. Nadmierne pobieranie przy obciążeniu dostępu losowego kosztuje realne pasmo i realne pieniądze na taryfowanych transportach, więc preferowana jest tania pomyłka nad drogą

Anulowanie, które faktycznie zatrzymuje transfer

Klasa bazowa deklaruje ReadAtCancellable, a źródło scalające honoruje to od początku do końca. Gdy nadchodzi odczyt pierwszoplanowy dla zakresu, którego nie obsługuje trwający prefetch, prefetch zostaje anulowany zamiast dokończony, więc żądanie strony użytkownika nie stoi w kolejce za spekulacyjnym ruchem. Domyślna implementacja w THPDFRandomAccessSource przechodzi na zwykłe ReadAt, co oznacza, że funkcja jest opcjonalna per transport: klienci HTTP obsługujący przerywanie żądań dostają prawdziwe anulowanie, a prostsze źródła nadal działają bez zmian

Połącz to z tokenem anulowania przeprowadzonym przez twój interfejs użytkownika, a użytkownik zamykający dokument faktycznie zatrzymuje ruch sieciowy, zamiast czekać, aż się dokończy. Ten sam model tokenów leży u podstaw kolejkowania opisanego w renderowaniu w tle z kolejką żądań, więc jeden token może pokryć całą ścieżkę od widoku po gniazdo

Odczytywanie statystyk pamięci podręcznej zakresów

GetStatistics wypełnia rekord THPDFRangeCacheStatistics, który oddziela to, co zrobił twój transport, od tego, co zrobiła pamięć podręczna. SourceReadCount i SourceBytesRead to ruch fizyczny. CacheHitCount i CacheMissCount to ruch logiczny. SequentialReadCount i RandomReadCount pokazują, jak sklasyfikowano wzorzec dostępu, CurrentReadAheadBlocks i PeakReadAheadBlocks pokazują, jak szeroko otworzyło się okno, a PrefetchRequestCount, PrefetchCompletedCount, PrefetchCancelledCount i SuppressedPrefetchCount pokazują, czy spekulacja się opłaciła

var
  S: THPDFRangeCacheStatistics;
begin
  Cached.GetStatistics(S);
  Log(Format('physical %d reads / %d bytes, hits %d, misses %d',
    [S.SourceReadCount, S.SourceBytesRead, S.CacheHitCount, S.CacheMissCount]));
  Log(Format('pattern: %d sequential, %d random, peak window %d blocks',
    [S.SequentialReadCount, S.RandomReadCount, S.PeakReadAheadBlocks]));
  Log(Format('prefetch: %d issued, %d completed, %d cancelled, %d suppressed',
    [S.PrefetchRequestCount, S.PrefetchCompletedCount,
     S.PrefetchCancelledCount, S.SuppressedPrefetchCount]));
end;

Trzy odczyty mówią ci, co zmienić. Wiele anulowanych prefetchy przy wysokiej liczbie odczytów losowych oznacza, że dokument jest odczytywany nie po kolei, więc obniż MaxReadAheadBlocks i przestań płacić za pasmo, które i tak odrzucasz. Wiele chybień przy oknie wciąż utrzymującym się na poziomie 1 oznacza, że tolerancja odrzuca wzorzec, który w praktyce jest sekwencyjny, więc podnieś SequentialReadToleranceBytes. A bajty odczytane znacznie przekraczające rozmiar pliku oznaczają, że pamięć podręczna się miota, więc podnieś MaxCacheBytes, zanim ruszysz cokolwiek innego

Zlinearyzowane pliki zmieniają arytmetykę

Jeśli kontrolujesz producenta, linearyzacja dokumentu zmienia problem, zamiast go optymalizować. Zlinearyzowany plik PDF umieszcza obiekty pierwszej strony i tablicę wskazówek na początku pliku, więc przeglądarka może wyrenderować pierwszą stronę z otwierającego megabajta, nie widząc reszty. HotPDF udostępnia tę ścieżkę bezpośrednio przez GetProgressiveLinearizedLoadInfo i ReadProgressiveLinearizedFirstPageSection, a strona zapisu jest omówiona w generowaniu zlinearyzowanych plików PDF z tablicami wskazówek

Obie techniki się łączą. Scalanie sprawia, że dowolny dokument staje się znośny przez wolne łącze; linearyzacja sprawia, że pierwsza strona pojawia się szybko w dokumentach, które sam produkujesz. W przypadku plików, które żyją na lokalnym dysku, ale są zbyt duże, by zmieścić się w pamięci, ścieżki mapowanego pliku i leniwego strumienia opisane w przepływie pracy bezpośredniego API plikowego są zwykle lepszym narzędziem, ponieważ nie ma tam w ogóle opóźnienia podróży w obie strony do zamortyzowania

HotPDF to natywny komponent VCL PDF dla Delphi i C++Builder, bez zewnętrznej biblioteki DLL dla analizatora i z pełnym dostępnym źródłem. API źródła o dostępie swobodnym, opakowanie scalające i punkty wejścia wczytywania progresywnego są udokumentowane na stronie komponentu HotPDF dla Delphi