Tehnički članak

Paralelno iscrtavanje PDF stranica u Delphiju: Sigurnost dretvi

Paralelno iscrtavanje PDF stranica iz Delphija svodi se na jedno pravilo: dajte svakoj radnoj dretvi vlastiti iscrtavač. losLab PDF Library izlaže RenderPagesToFilesParallel za upravo taj zadatak, raspoređujući raspon stranica kroz skup TTask zadataka s jednom instancom TPDFlib po radniku, tako da višestazni stroj pretvara serijski rasterski posao u propusnost koja se skalira blizu broja jezgri. Ako umjesto toga dijelite jednu instancu između dretvi, izvođenje se neće samo usporiti, već će doći do oštećenja memorije i rušenja sustava

Ovo je članak koji tražite kada noćni posao mora pretvoriti PDF od 500 stranica u 500 PNG slika, stroj ima 16 slobodnih jezgri, a vaš prvi ozbiljan pokušaj uvođenja višenatječnosti je zakazao unutar GDI+ biblioteke. Ukratko, sigurnost dretvi ovdje je strukturno svojstvo, a ne zastavica koju postavljate, a ostatak ovog teksta objašnjava zašto siguran oblik izgleda ovako kako izgleda i gdje se zapravo nalazi stvarna granica ubrzanja

Je li TPDFlib siguran za dretve pri paralelnom iscrtavanju?

Ne, a razlog vrijedi razumjeti prije nego što počnete dizajnirati oko toga. Jedna instanca TPDFlib deklarirana je za jednokratnu upotrebu, a kritična točka je TPDFPageTree.GetPage: ona upisuje dijeljeno polje FPagePointer u instancu kao nuspojavu odabira stranice. Dvije dretve koje pozivaju istu instancu natječu se za to polje, tako da radnik A može biti na pola puta kroz iscrtavanje stranice 3 kada radnik B ponovno usmjeri stablo stranica na stranicu 40. Ništa u API-ju vas ne sprječava da napišete kôd u nastavku, a on će čak i raditi za nekoliko stranica prije nego što se sruši, što je najgori mogući način za ponašanje ovakve pogreške

// DO NOT do this: one shared instance, many threads
var
  Pdf: TPDFlib;
begin
  Pdf := TPDFlib.Create;
  Pdf.LoadFromFile('report.pdf', '');
  TParallel.For(1, Pdf.PageCount,
    procedure(Page: Integer)
    begin
      // every thread reenters the same instance -> data race on FPagePointer
      Pdf.RenderPageToFile(150, Page, 0, 'page' + IntToStr(Page) + '.png');
    end);
  Pdf.Free;
end;

Failure nije deterministički, što je upravo razlog zašto preživljava brzi test, a zatim se pojavljuje na korisničkom računalu s različitim brojem jezgri i težim dokumentom. Ne postoji ni zaključavanje koje možete omotati oko RenderPageToFile da biste to jeftino riješili, jer držanje mutexa tijekom cijelog poziva za iscrtavanje serijalizira rad i odbacuje paralelizam zbog kojeg ste došli

Zašto svaki radnik za iscrtavanje treba vlastitu TPDFlib instancu?

Zato što je instanca jedinica izolacije. Nakon što svaki radnik posjeduje privatni TPDFlib koji je neovisno učitao datoteku, svaki ima svoje stablo stranica, svoj FPagePointer i svoje stanje iscrtavanja, pa nema ničeg dijeljenog oko čega bi se natjecali. Ta sigurnost ima cijenu koju biste trebali procijeniti unaprijed: svaki radnik analizira cijeli dokument u memoriji, tako da je vršno zauzeće memorije otprilike N puta veće od cijene jedne instance. Osam radnika na PDF-u od 300 MB znači osam potpunih analiza koje su istovremeno u memoriji, a kod vrlo velikih unosa to je ograničenje koje odlučuje o broju radnika, a ne procesor. Kada je dokument ogroman i ograničeni ste memorijom, a ne procesorom, put izravnog pristupa opisan u članku o obradi velikih PDF-ova bez analize cijelog dokumenta često je bolji izbor od uvođenja više dretvi za iscrtavanje

API s jednim pozivom: RenderPagesToFilesParallel

losLab PDF Library pakira cijeli siguran obrazac iza jedne metode, tako da ga za uobičajene slučajeve ne morate sami pisati. RenderPagesToFilesParallel prima naziv datoteke i lozinku, DPI, uključenu početnu i završnu stranicu, vrijednost Options koja se prenosi izravno u rastersku putanju po stranici, izlazni uzorak u kojem se %p zamjenjuje brojem stranice i ograničenje broja radnika gdje bilo koja vrijednost nula ili manja označava automatski odabir. Metoda vraća broj uspješno iscrtanih stranica i to je putanja dostupna samo na Windowsima jer se oslanja na CoInitialize i 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;

Zašto CoInitialize na svakoj radnoj dretvi?

GDI+ je rasterizator ispod iscrtavanja stranice, a GDI+ koristi apartmansku višenatječnost: očekuje da se COM inicijalizira na dretvi koja ga poziva. Glavna dretva VCL aplikacije obično to već ima postavljeno, ali svježe stvoreni TTask radnik nema, a pozivanje putanje iscrtavanja iz neinicijalizirane dretve pouzdan je način za rušenje sustava. Stoga svaki radnik uparuje CoInitialize(nil) na početku s CoUninitialize na izlazu, uokvirujući cijeli svoj životni vijek. To je ista disciplina koju bilo koji rad s GDI+ ili COM-om zahtijeva izvan glavne dretve, i to je druga polovica onoga što izolaciju po radniku čini učinkovitom, pri čemu je prva polovica privatna instanca. Ista GDI+ rasterska putanja pokreće jednodretvene sustave opisane u članku o odabiru sustava za iscrtavanje za PDF izlaz

Statičko dijeljenje nasuprot dinamičkom preuzimanju stranica

Očit način raspodjele 500 stranica na 8 radnika je dodjela fiksne kriške od oko 62 stranice svakom radniku. losLab PDF Library to ne čini, a razlog je uravnoteženje opterećenja. Zahtjevnost stranice drastično varira: stranica s čistim tekstom iscrtava se u milisekundama, dok stranica s gustim vektorskim kartama ili skeniranom slikom preko cijelog zaslona može trajati pedeset puta dulje. Podijelite li posao na fiksne kriške, radnik koji crta zahtjevnu krišku radit će dugo nakon što ostali završe, pa je vaše ukupno vrijeme određeno najnesretnijom kriškom, a ne prosjekom. Umjesto toga, svaki radnik traži sljedeću stranicu iz zajedničkog brojača pod kratkom kritičnom sekcijom, iscrtava je i vraća se po novu, što drži svaku jezgru zaposlenom dok se cijeli raspon ne potroši

// What each worker does inside the pool (simplified)
NextPage := StartPage;
IdxLock := TCriticalSection.Create;
WorkerProc :=
  procedure
  var
    LocalLib: TPDFlib;
    PageNum: Integer;
  begin
    CoInitialize(nil);              // GDI+ is apartment-threaded
    try
      LocalLib := TPDFlib.Create;   // one private instance per worker
      try
        LocalLib.LoadFromFile(FileName, '');
        while True do
        begin
          IdxLock.Enter;            // claim the next page atomically
          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;

Strukturirano bilježenje u radnim dretvama

Uklanjanje pogrešaka u seriji koja se prekine na stranici 213 od 500 je nemoguće bez zapisa (log), no jednostavan zapis sam po sebi predstavlja problem s konkurentnošću. losLab PDF Library isporučuje TPDFlibLogger, povezan putem svojstva TPDFlib.Logger, koji je zadano postavljen na nil kako bi putanja bez bilježenja ostala bez dodatnih troškova. Temelji se na povratnim pozivima: postavite OnLog i usmjerite zapise kamo god domaćin želi, filtrirano prema razinama llDebug / llInfo / llWarn / llError, a PDFlibErrorMessage pretvara sirove brojčane kodove u tekst čitljiv ljudima kako bi zapis Error bio jasniji od samog broja. Izborni izlaz u datoteku jedini je zajednički resurs i zaštićen je s TCriticalSection upravo zato da bi nekoliko radnika moglo sigurno pisati u istu datoteku. Imajte na umu stvarnu granicu: samo je taj izlaz sinkroniziran, pa ako dijelite jedan loger u vlastitom skupu dretvi, a vaš OnLog dira korisničko sučelje, i dalje to morate sami prenijeti natrag na glavnu dretvu

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 to the UI thread yourself; OnLog fires on worker threads
        WriteLn(Format('[%d] %s', [Code, PDFlibErrorMessage(Code)]));
    end;
  Pdf := TPDFlib.Create;
  Pdf.Logger := Log;                     // nil by default; zero-cost when unset
  try
    Pdf.RenderPagesToFilesParallel('report.pdf', '', 150.0, 1, 500, 0,
      'out\page_%p.png', 0);
    // an Error now carries text, e.g. 401 -> "Wrong password or permission denied"
  finally
    Pdf.Free;
    Log.Free;
  end;
end;

Koliko ubrzanje zapravo trebate očekivati?

Budite iskreni prema sebi o tome kamo odlazi vrijeme, jer se paralelno iscrtavanje isplati samo kada je posao uistinu ograničen procesorom. Izlaz visokog DPI-ja i složene vektorske ili osjenčane stranice zahtijevaju mnogo računanja, a to se skalira gotovo linearno s brojem jezgri dok ne zasitite procesor. Jednostavne stranice su druga priča: kod njih režijski trošak LoadFromFile po radniku, zajedno s troškom pisanja izlaznih datoteka na disk, može nadvladati samo iscrtavanje, pa osam radnika koji opterećuju jedan spori disk mogu završiti sporije od čiste serijske petlje. Postavite MaxWorkers na svoj fizički broj jezgri, a ne na neku željenu vrijednost, pratite memoriju kada je izvorni PDF velik, a ako se pokaže da je serijski posao ograničen ulazom/izlazom (I/O), rješenje je brža pohrana ili manje radnika, a ne više dretvi. Korištena za poslove za koje je napravljena, ovdje prikazana putanja serijskog iscrtavanja dio je standardne knjižnice losLab PDF Library za Delphi i C++Builder, i pretvara slobodne jezgre u dovršene stranice bez ijedne od zamki sa sigurnošću dretvi koje biste inače morali sami rješavati