技術記事

Delphiにおける並行ZIP Inflate: HotXLSリードゲート

Delphi・C++Builder向けのネイティブExcelコンポーネントライブラリであるHotXLSは、複数のXLSXワークシートを、1つの開いたZIPパッケージから同時に伸長する。その仕組みはTZipReadGateであり、lxZipArchive.pas内の小さなクラスで、パッケージストリームと1つのクリティカルセクションを保持し、正確に1つのメソッドを公開する。それはseekとreadのペアを直列化する。そのペアより上のすべては並行に実行される

この設計を強いた問題は、大きなワークブックを開いたことのあるすべてのDelphi開発者が出会ってきたものだ。80MBのxlsxは80MBのdeflate済みXMLであり、その中のワークシートパーツはおおよそ5倍から10倍に展開される。開く側のパスが各ワークシートをパースする前にメモリストリームに抽出するなら、構築中のワークブックに加えて伸長されたバイト分のコストを支払うことになり、そのピークは1つのセルも作られる前に到来する。この記事は、そのステージング処理を取り除くパッケージレベルの並行性についてだ。その上に座るアロケータの上限は並列XLSXパースとメモリマネージャに関する記事で扱っており、1度読んで決して実体化しないAPIはストリーミングダイレクトリーダーの解説で扱っている

なぜ古い開くパスはすべてのワークシートをRAM上でステージングしていたのか

HotXLSの元の並列open処理は3フェーズのパイプラインであり、ワーカーで実行されるのは中間フェーズだけだった。フェーズAはシートリストを直列に歩き、各ワークシートを作成し、その関係パーツを読み、伸長済みのワークシートXML全体をプライベートなTMemoryStreamにコピーした。フェーズBはParseWorksheetXmlをプールにファンアウトさせた。フェーズCは、コメント、スレッド付きコメント、描画、グラフ、テーブルといった小さな衛星パーツについて、呼び出し元のスレッドでアーカイブに戻った。その形は明言された理由から選ばれていた。lxParallelParse.pasのヘッダーコメントは、かつて言葉通りに、zipアーカイブとそのinflate状態はスレッドセーフではないと述べており、内部のノートはさらに踏み込んでいた:アーカイブをロックすることに手間をかけるな、なぜならエントリごとにinflate状態が直列化されている時点で、ロックは何も買わないからだ、と。フェーズAは、アーカイブへのすべての接触を1つのスレッドに留めるために存在していた。そのコストは、8つの活発なシートを持つワークブックが、伸長済みのワークシートXMLバッファを8つ同時にメモリ上に保持することであり、これらのバッファはオープンパス全体の中で最大の一時オブジェクトだった

2つのスレッドは1つのZIPストリームから伸長できるのか

できる、そして以前の判断は特定の、特定できる形で間違っていた:それは2つの異なる状態の断片を1つの文に折りたたんでしまっていたのだ。Inflate状態は本当に共有不可能だ。zlibのz_streamは、1つの圧縮メンバーのためにスライディングウィンドウ、ハフマンテーブル、ビット位置を保持しており、2つのスレッドが同じものにバイトを押し込むとゴミが生成される。基礎となるバイトソースはまったく別の問題であり、そこでの答えは、ファイルストリームは保護する価値のある可変な共有状態を正確に1つだけ持っている、つまりその位置カーソルだ、ということだ

ZIPコンテナがその分離を合法にしている。ZIPアーカイブ内の各メンバーは独立に圧縮されている:自身のローカルファイルヘッダー、自身のDataOffsetにおける自身のdeflateビットストリーム、中央ディレクトリにある自身のCRC32とサイズだ。ソリッドな7zブロックのようにメンバーをまたぐ共有辞書はないので、エントリNはエントリMに触れることなく伸長できる。各ワーカーに自身のバイト範囲に対する自身のz_streamを与えれば、それらが衝突するのはseekだけだ。その衝突を取り除くのがTZipReadGateであり、そのクラス全体は1画面で読める程度に短い

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;

TZipReadGateが保護するもの、そして意図的に保護しないもの

TZipReadGate.ReadAtは、共有ストリームを位置決めしてそこから読むという1つの不可分な操作だけを保護し、それ以外は何も保護しない。TZipArchive.OpenArchiveは、中央ディレクトリが正常にパースされた後、FInputStreamの上にこのゲートを構築し、TZipArchive.Closeはそれを解放する。書き込み用に開かれたアーカイブは決してゲートを持たない。したがってワーカーがパッケージに対して実行するすべての読み取りは、1回の読み取りが行われる間だけ保持される単一のクリティカルセクションを通じて漏斗のように集まる

それ以外のすべては、すでにプライベートであるか、すでに不変であるかのどちらかなので、ロックの外に留まる。TZipSubStreamは自身のFPositionを保持しているので、各ワーカーは自身のエントリ内の自身の場所を追跡する。TZipEntry.GetStreamがそのサブストリームの上に構築するTZLibStreamはエントリごとであり、生のdeflateのためにwindowBitsを-15にして作成され、決して共有されない。中央ディレクトリは、すべてのローカルヘッダーを含めて、どのワーカーも開始する前に完全にパースされているので、並行性が始まる時点でGetEntryByNameは読み取り専用のハッシュルックアップになっている。ルーティング自体はTZipSubStream.Readの中の3行であり、ゲートなしの分岐が、既存のすべてのシングルスレッド呼び出し側を古いコードパスに留めている

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;

競合下でこのゲートはどれほどのコストがかかるのか

「アーカイブへのグローバルロック」というフレーズが示唆するよりも小さい。それはTZLibStreamがたまたま使っている粒度のおかげだ。その入力バッファはBufferSizeで、$4000として定義されているので、ReadInputBufferは1回のリフィルで16KBの圧縮バイトを取り込み、それをzng_inflateに渡す。したがって1回のロック取得は16KB分のdeflate入力をカバーし、ワークシートXMLについてはそれがおおよそ10万バイトのマークアップに展開され、それをワーカーは何も保持せずにデコードしパースする。ロックは、オペレーティングシステムのキャッシュに対する位置決め済み読み取りの間だけ保持される。それがゲートしている作業はミリ秒単位で測られる

その比率が反転する正直な境界がある。deflateされるのではなく格納されているエントリは、それを隠すためのinflate作業が一切ないので、ゲートを1対1で読み通す。したがって格納メンバーで一杯のパッケージは、はるかに強く直列化されるだろう。低速メディア上の冷たいファイルはクリティカルセクションを広げる。その中の読み取りはキャッシュヒットではなく本物のディスク転送になるからだ。そして数人のワーカーを超えると、いずれにせよ最初にぶつかるのはゲートではない:ワークシートのパースは割り当て負荷が高く、Delphiのメモリマネージャは、リードゲートが制約になるずっと前に、スレッドをまたぐ割り当てを直列化する。だからこそTXLSXWorkbook.ParallelParseThreadsのデフォルトは、コアごとに1スレッドではなく自動上限になっている

ワーカー本体、そして忘れやすいドレインループ

ゲートが備わったことで、HotXLSはフェーズAのステージングをまるごと削除した。ワーカーは今や自身のエントリストリームを開き、それをそのままパーサーに供給する。2つの一時フィールドが入力を運ぶ:FParZipは並列フェーズの間アーカイブを保持し、FParSheetPartNamesはパーツ名を保持し、どちらもfinallyブロックでクリアされるので、失敗したオープンの後に古いポインタが生き残ることはない。TZipArchive.OpenFileから戻ってくるストリームは、TZipSubStreamをラップするTZLibStreamをラップするTZipVerifiedStreamであり、一番外側のものを解放することでそのチェーン全体が解放される

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;

ドレインループは、古いコードを素直に移植しただけでは抜け落ちてしまう細部であり、抜け落ちると完全性チェックが黙って無効化される。TZipVerifiedStreamはバイトが通過するにつれて実行中のCRC32を蓄積し、その位置が中央ディレクトリに記録された非圧縮サイズに到達したときにのみVerifyCompleteを呼び出す。それがサイズ不一致とCRC32不一致の例外の出どころであり、加えて宣言より長いエントリを捕捉する1バイトの探り読みもそこから来る。XMLリーダーは閉じ要素で止まり、通常は改行やわずかな末尾の空白を未読のまま残すので、ドレインなしでは位置が決して宣言されたサイズに到達せず、チェックは決して発火しない。残りをスクラッチバッファに読み込むことは何のコストもかからず、それらを復元する。ステージング用ストリームが存在していた頃は、XlsxCopyStreamAllが偶然これを行っていた

今も直列に実行されるもの、そしてすべてをオフにするフラグ

フェーズAは、抽出処理を除いて生き残っている。それは今も呼び出し元のスレッドで各ワークシートを作成しその関係を読んでおり、それがワーカーが始まった時点ですべての共有マップを不変にしている理由だ。フェーズCは今もコメント、描画、グラフ、テーブルについてシートを直列に後歩きし、そのガードは古いステージング配列に対するnullチェックから、パーツ名に対するzip.Existsに変わった。ワーカーが触れる共有の読み取り専用入力、共有文字列テーブルとcellXfマップは、フェーズBが始まる前に完成しており、その間に一切書き込まれない

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;

Openの前にParallelParseをFalseに設定すると、同じジョブプロシージャがスレッド数1でディスパッチされ、RunParallelJobsは呼び出し元のスレッド上の単純なループに退化する。これは2つの理由で知っておく価値がある:現場でスレッド周りの懸念が浮上した場合の1行の回答であり、また直列パスと並列パスが分岐する2つの本体ではなく1つのパース処理本体を共有していることを意味する。ワーカーの例外は捕捉され、最も小さいジョブインデックスが勝ち、そのエラーはすべてのワーカーがjoinした後に呼び出し元のスレッドで再発生させられるので、壊れたワークシートはそれでも期待される場所で1つの例外として表面化する。周囲のオープンパスの一般的なチューニングはDelphiにおける大容量ワークブックパフォーマンスガイドで扱っている

ここで説明したリードゲート、並列openフェーズ、ストリーミングエントリアクセスは、フルソース付きでDelphi・C++Builder向け標準HotXLS Excelコンポーネントの一部として出荷されている。製品ページには、並列openのプロパティを含む完全なTXLSXWorkbookリファレンスがある