Tehnički članak

Konkurentan ZIP inflate u Delphi: HotXLS read gate

HotXLS, nativna biblioteka Excel komponenti za Delphi i C++Builder, napumpava nekoliko XLSX radnih listova istovremeno iz jednog otvorenog ZIP paketa. Mehanizam je TZipReadGate, mala klasa u lxZipArchive.pas koja drži tok paketa plus jednu kritičnu sekciju i izlaže tačno jednu metodu. Serijalizuje par seek-i-read. Sve iznad tog para radi konkurentno

Problem koji je iznudio ovaj dizajn je onaj koji je svaki Delphi programer koji je otvorio veliku radnu svesku sreo. Xlsx od 80 MB je 80 MB deflate-ovanog XML-a, a delovi radnog lista unutar njega se šire otprilike pet do deset puta. Ako vaša putanja otvaranja izvlači svaki radni list u tok u memoriji pre parsiranja, plaćate za napumpane bajtove pored radne sveske koju gradite, a pik stiže pre nego što je ijedna ćelija napravljena. Ovaj članak se bavi konkurentnošću na nivou paketa koja uklanja taj korak pripreme. Tavanica alokatora koja sedi iznad toga pokrivena je u članku o paralelnom XLSX parsiranju i memory manager-u, a API čitaj-jednom-nikad-ne-materijalizuj pokriven je u pregledu streaming direktnog čitača

Zašto je stara putanja otvaranja pripremala svaki radni list u RAM-u

Originalno paralelno otvaranje u HotXLS bilo je pipeline od tri faze, i srednja faza je bila jedina koja je radila na worker-ima. Faza A je serijski obilazila listu listova, kreirala svaki radni list, čitala njegov deo relacija, i kopirala ceo napumpan XML radnog lista u privatan TMemoryStream. Faza B je razgranala ParseWorksheetXml preko pool-a. Faza C se vraćala u arhivu na pozivajućoj niti za male satelitske delove: komentare, threaded komentare, crteže, grafikone, tabele. Taj oblik je izabran iz navedenog razloga. Komentar u zaglavlju na lxParallelParse.pas je nekada rečima govorio da zip arhiva i njeno inflate stanje nisu thread-safe, a interne beleške su išle dalje: nema smisla zaključavati arhivu, jer čim se stanje inflate-a serijalizuje po unosu, brava ne kupuje ništa. Faza A je postojala da drži svaki dodir arhive na jednoj niti. Cena je bila da je radna sveska sa osam zauzetih listova držala osam potpuno napumpanih bafera XML-a radnog lista u memoriji istovremeno, a ti baferi su najveći prolazni objekti u čitavoj putanji otvaranja

Mogu li dve niti da napumpaju iz jednog ZIP toka?

Mogu, i stara procena je bila pogrešna na specifičan, lociv način: sabila je dva različita komada stanja u jednu rečenicu. Stanje inflate-a zaista nije deljivo. zlib z_stream nosi klizeći prozor, Huffman tabele i bitsku poziciju za jedan komprimovan član, a dve niti koje guraju bajtove kroz isti proizvode smeće. Osnovni izvor bajtova je potpuno drugačije pitanje, a odgovor tamo je da tok fajla ima tačno jedan komad promenljivog deljenog stanja vrednog zaštite, svoj kursor pozicije

ZIP kontejner čini razdvajanje legalnim. Svaki član u ZIP arhivi je komprimovan nezavisno: sopstveno lokalno zaglavlje fajla, sopstveni deflate bit tok na sopstvenom DataOffset, sopstveni CRC32 i veličine u central directory-u. Nema deljenog rečnika koji se prostire preko članova onako kako to ima blok solid 7z, tako da se unos N može napumpati bez dodirivanja unosa M. Dajte svakom worker-u sopstveni z_stream preko sopstvenog opsega bajtova i jedino na čemu se sudaraju je seek. Taj sudar je ono što TZipReadGate uklanja, a cela klasa je dovoljno kratka da se pročita na jednom ekranu

type
  TZipReadGate = class
  private
    FBaseStream: TStream;
    FLock: TRTLCriticalSection;
  public
    constructor Create(ABaseStream: TStream);
    destructor Destroy; override;
    function ReadAt(AOffset: Int64; var Buffer; Count: Longint): Longint;
  end;

function TZipReadGate.ReadAt(AOffset: Int64; var Buffer;
  Count: Longint): Longint;
begin
  if Count <= 0 then
  begin
    Result := 0;
    Exit;
  end;
  EnterCriticalSection(FLock);
  try
    FBaseStream.Position := AOffset;
    Result := FBaseStream.Read(Buffer, Count);
  finally
    LeaveCriticalSection(FLock);
  end;
end;

Šta TZipReadGate štiti, a šta namerno ne štiti

TZipReadGate.ReadAt čuva jednu nedeljivu operaciju, pozicioniranje deljenog toka i čitanje iz njega, i ništa drugo. TZipArchive.OpenArchive konstruiše kapiju nad FInputStream čim se central directory uspešno parsira, a TZipArchive.Close je oslobađa. Arhive otvorene za pisanje je nikad ne dobijaju. Svako čitanje koje worker izvrši na paketu zato se sliva kroz jednu kritičnu sekciju koja se drži tokom jednog baferovanog čitanja

Sve ostalo ostaje van brave jer je već privatno ili već nepromenljivo. TZipSubStream drži sopstveni FPosition, tako da svaki worker prati sopstveno mesto u sopstvenom unosu. TZLibStream koji TZipEntry.GetStream gradi nad tim sub-tokom je po unosu, napravljen sa windowBits od -15 za sirovi deflate, i nikad deljen. Central directory je u potpunosti parsiran pre nego što ijedan worker počne, uključujući svako lokalno zaglavlje, tako da je GetEntryByName pretraga hash-a samo za čitanje do trenutka kad konkurentnost počne. Samo usmeravanje su tri reda u TZipSubStream.Read, a grana bez kapije je ono što drži svakog postojećeg pozivaoca sa jednom niti na staroj putanji koda

function TZipSubStream.Read(var buffer; Count: longint): longint;
var
  rest: Int64;
  rc: longint;
begin
  rest := FSize - FPosition;
  if (Count > rest) then
    Count := rest;
  if FReadGate <> nil then
    rc := FReadGate.ReadAt(FOffset + FPosition, buffer, Count)
  else
  begin
    FBaseStream.Position := FOffset + FPosition;
    rc := FBaseStream.Read(buffer, Count);
  end;
  FPosition := FPosition + rc;
  Result := rc;
end;

Koliko košta kapija pod konkurencijom?

Manje nego što fraza "globalna brava na arhivi" sugeriše, zbog granularnosti koju TZLibStream slučajno koristi. Njegov ulazni bafer je BufferSize, definisan kao $4000, tako da ReadInputBuffer povlači 16 KB komprimovanih bajtova po dopuni i predaje ih zng_inflate. Jedno preuzimanje brave zato pokriva 16 KB deflate ulaza, što se za XML radnog lista širi u nešto reda veličine 100 KB markup-a koji worker zatim dekoduje i parsira bez ičega da drži. Brava se drži za pozicionirano čitanje naspram keša operativnog sistema; posao koji ograničava meri se u milisekundama

Iskrena granica je gde se taj odnos okreće. Unosi skladišteni umesto deflate-ovani čitaju kroz kapiju jedan-na-jedan bez posla inflate-a da sakriju latenciju, tako da bi paket pun skladištenih članova serijalizovao mnogo teže. Hladan fajl na sporom medijumu širi kritičnu sekciju, jer je čitanje unutar nje sada pravi transfer diska, a ne pogodak keša. I posle šačice worker-a kapija svakako nije prvo na šta udarite: parsiranje radnog lista je teško alokacijama, a Delphi memory manager serijalizuje alokacije kroz niti mnogo pre nego što read gate postane ograničenje. Zato je TXLSXWorkbook.ParallelParseThreads podrazumevano automatska tavanica umesto jedne niti po jezgru

Telo worker-a, i petlja pražnjenja koju je lako zaboraviti

Sa kapijom na mestu, HotXLS je u potpunosti obrisao pripremu Faze A. Worker sada otvara sopstveni tok unosa i hrani ga direktno parseru. Dva prolazna polja nose ulaze: FParZip drži arhivu za trajanje paralelne faze, FParSheetPartNames drži imena delova, i oba se čiste u finally bloku tako da nijedan zastareo pokazivač ne preživi neuspelo otvaranje. Tok koji se vraća iz TZipArchive.OpenFile je TZipVerifiedStream koji obavija TZLibStream koji obavija TZipSubStream, a oslobađanje spoljašnjeg oslobađa lanac

procedure TXLSXWorkbook.ParseSheetJob(AIndex: Integer);
var
  Stream: TStream;
  DrainBuffer: array [0..32767] of Byte;
  PartName: WideString;
begin
  PartName := WideString(FParSheetPartNames[AIndex]);
  Stream := FParZip.OpenFile(PartName);
  if Stream = nil then
    Exit;
  try
    ParseWorksheetXml(Stream, FSheets.ByPos[AIndex], FParSst,
      FParRels[AIndex], FParFontMap, FParFillMap, FParBorderMap,
      FParNumFmtMap, FParAlignMap, FParProtMap, FParDateMap);
    // Consume any trailing bytes so the ZIP entry size and CRC are verified.
    while Stream.Read(DrainBuffer, SizeOf(DrainBuffer)) > 0 do
      ;
  finally
    Stream.Free;
  end;
end;

Petlja pražnjenja je detalj koji bi direktan port starog koda ispustio, a ispuštanje ga tiho isključuje proveru integriteta. TZipVerifiedStream akumulira tekući CRC32 dok bajtovi prolaze i poziva VerifyComplete samo kad njena pozicija dosegne nekomprimovanu veličinu zabeleženu u central directory-u; tu dolaze izuzeci neslaganja veličine i CRC32, plus čitanje probe od jednog bajta koje hvata unos duži od deklarisanog. XML čitač se zaustavlja na zatvarajućem elementu i obično ostavlja novi red ili nekoliko bajtova pratećeg razmaka nepročitanim, tako da bez pražnjenja pozicija nikad ne dosegne deklarisanu veličinu i provere se nikad ne aktiviraju. Čitanje ostatka u privremeni bafer ne košta ništa i vraća ih. Kad su postojali baferi pripreme, XlsxCopyStreamAll je ovo radio slučajno

Šta i dalje radi serijski, i zastavica koja sve to isključuje

Faza A preživljava, minus ekstrakcija. I dalje kreira svaki radni list i čita njegove relacije na pozivajućoj niti, što je ono što ostavlja svaku deljenu mapu nepromenljivom čim worker-i počnu. Faza C i dalje serijski obilazi listove posle toga za komentare, crteže, grafikone i tabele, a njena straža se promenila iz null provere na starom nizu pripreme u zip.Exists naspram imena dela. Deljeni ulazi samo za čitanje koje worker-i dodiruju, deljena tabela stringova i mape cellXf, su kompletni pre nego što Faza B počne i nikad se ne pišu tokom nje

var
  Wb: TXLSXWorkbook;
begin
  Wb := TXLSXWorkbook.Create;
  try
    Wb.ParallelParse := True;      // default; False forces one sheet at a time
    Wb.ParallelParseThreads := 4;  // 0 selects the automatic cap
    Wb.Open('quarterly-consolidation.xlsx');
    // ... workbook is identical either way ...
  finally
    Wb.Free;
  end;
end;

Postavljanje ParallelParse na False pre Open šalje istu proceduru posla sa brojem niti jedan, i RunParallelJobs degeneriše u običnu petlju na pozivajućoj niti. Vredi to znati iz dva razloga: to je jednoredni odgovor ako se ikad na terenu pojavi briga oko threading-a, i znači da serijska i paralelna putanja dele jedno telo koda parsiranja umesto da se razilaze. Izuzeci worker-a se hvataju, pobeđuje najniži indeks posla, a greška se ponovo izaziva na pozivajućoj niti pošto se svaki worker pridruži, tako da se pokvaren radni list i dalje pojavljuje kao jedan izuzetak na očekivanom mestu. Opšte podešavanje okolne putanje otvaranja pokriveno je u vodiču za performanse velikih radnih svezaka u Delphi

Read gate, paralelna faza otvaranja i streaming pristup unosima opisani ovde isporučuju se kao deo standardne HotXLS Excel komponente za Delphi i C++Builder, sa punim izvornim kodom; stranica proizvoda nosi kompletnu TXLSXWorkbook referencu uključujući svojstva paralelnog otvaranja