技術記事

HotXLS Delphi Component: Delphi での large workbook performance

30万行のエクスポートがメモリ予算を吹き飛ばすと、たいてい行数が非難されます。行数はたいてい無実です。大きなワークブックのコストがかさむ部分は、副作用として生まれるものです。ループの中で書式設定を追加したために 1 セルごとに 1 エントリずつ膨らんでいくスタイルプール、保存時に 1 つの巨大な文字列として組み立てられるワークシート XML、1 つずつ保存される 100万個の同一の数式本体。losLab の XLS/XLSX 向けネイティブ Delphi ライブラリである HotXLS は、これらのコストのそれぞれに対して特定のレバーを用意しています。どれも既定では有効になっていません。それぞれがトレードオフを変えるからであり、どの症状にどのレバーが対応するかを知ることこそが、実際の性能チューニングのスキルです

大きなワークブックがどこでメモリを消費するか

考えるべきメモリの状況には 2 つの異なる局面があります。生成中は、触れたセルごとにメモリ上のセルモデルが成長します。値、書式、数式はすべてオブジェクトかプールのエントリになります。保存中は、既定の XLSX パスはさらに、zip コンテナに圧縮する前に各ワークシートの XML を幅の広い文字列としてレンダリングするため、ピーク使用量はモデルに加えて最大のシートのシリアライズ済みの形になります。構築ループを生き延びた後 SaveAs の中で死ぬジョブは、1 つ目ではなく 2 つ目の局面にぶつかっているのであり、片方の修正はもう片方には何の効果もありません

Delphi HotXLS 大規模ブックジョブの 2 つのメモリ体制。生成ループが構築するメモリ内セルモデルと、既定保存中の最大シートのシリアライズ済み XML 文字列。後者は StreamingWrite が除去
構築ループと保存呼び出しは異なる 2 つのメモリ体制で失敗するため、StreamingWrite が平らにするのは保存時のスパイクだけであり、構築経路のメモリにはスタイルプールとコールバックのレバーが必要です

ファイルサイズも関連したルールに従います。セルは、スタイル、共有文字列、数式、画像、コメントと並ぶ、寄与要因の 1 つに過ぎません。ForEachCell とシートごとのコレクションのカウントを使った監査パスは、間違ったものを最適化する前に、どのリソースが実際に問題のファイルを支配しているかを教えてくれます。測定上の細かい注意点が 1 つあります。XLSX 側の Sheet.Cells.Count は、使用範囲の面積ではなく、疎な格納方式の中に実体化されたセルの数を報告します。データが 1000 行 50 列の矩形を占め、その半分が空であるシートは、5万ではなくおおよそ 2万5000 とカウントされます。この区別は、顧客の「巨大な」ファイルを自分のフィクスチャと比較するときに重要になります。疎な財務レイアウトでは、使用範囲の面積と実際のセルの充填数が 1 桁違うことがあるからです

StreamingWrite が直すのは保存パスであり、構築パスではない

TXLSXWorkbook.StreamingWrite := True を設定すると、SaveAs は、シートごとの文字列の中間段階を排除して、ワークシート XML を zip ストリームへ直接書き込むストリーミングシリアライザに切り替わります。動作の互換性を保つため既定は False であり、これを有効にするのは 1 行の変更です

Book := TXLSXWorkbook.Create;
try
  Sheet := Book.Sheets.Add('Bulk');
  for R := 1 to 100000 do
  begin
    Sheet.Cells[R, 1].Value := R;
    Sheet.Cells[R, 2].Value := 'Row ' + IntToStr(R);
    Sheet.Cells[R, 3].Value := R * 1.5;
  end;
  Book.StreamingWrite := True;   // シート XML が zip コンテナへストリーミングされる
  Book.SaveAs('bulk.xlsx');
finally
  Book.Free;
end;

これが何をもたらすのかは正確に理解しておいてください。ループによって構築されたセルモデルは、以前とまったく同じ量のメモリを占めます。StreamingWrite は保存時のスパイクを平らにするものであり、それが、95%地点で失敗するバッチジョブと完了するバッチジョブの違いを生みます。もし構築ループ自体がメモリを使い果たしているなら、必要なレバーは次の 2 つです

スタイルプール: 一度だけ追加し、インデックスを再利用する

HotXLS の XLSX 書式設定はプールベースです。Book.Fonts.Add(...)Fills.AddSolid(...)Borders.Add(...) はセルが参照する 0 始まりのプールインデックスを返します。ループの中で同じパラメータを使って Fonts.Add を呼び出すことは重複排除されるため、無駄になるのは空間ではなく時間です。Alignments.Add は違う振る舞いをします。呼び出しごとに新しいオブジェクトを返すため、セルごとの配置設定はプールを行数に比例して線形に増やしていきます。この両方のケースをカバーする習慣が 1 つあります。すべてのプールインデックスをループの外で一度だけ解決し、インデックスの代入だけをループの中で行うことです

HotXLS Delphi スタイルプール使用の比較。行ごとに 1 つ作られる新しい Alignments.Add オブジェクトはプールを線形に成長させる一方、ループの上で 1 回解決された巻き上げ済み Fonts.Add インデックスは全セルで再利用され、0 基準インデックスが 1 ずれる
フォント、フィル、罫線、配置の各インデックスをループの外で一度解決し、ループの中ではそれを 1 ずらした 0 起点プールインデックスを代入します
// プールの検索はホットループの外へ持ち出す
HeaderFont := Book.Fonts.Add('Calibri', 11, True, False);   // 0 始まりのプールインデックス
for C := 1 to 24 do
  Sheet.Cells[1, C].FontIndex := HeaderFont + 1;            // セル側は 1 始まりで保存する。0 = 既定

+ 1 は誤植ではなく、それを忘れることこそがここで典型的な症状を生む間違いです。プールは 0 始まりのインデックスを渡す一方で、セル側のプロパティは 0 を「既定」として扱うため、すべてのプールインデックスは代入時に 1 だけずらさなければなりません。これを書き忘れて間違えると、見出しは静かにワークブックの既定フォントで描画され、ブランディングレビューまで誰も気づかない不具合になります

セルごとの Variant トラフィックを行コールバックで置き換える

Sheet.Cells[R, C].Value := X のすべてが、セルのルックアップまたは作成に加えて Variant の代入を伴います。数十万セルにもなると、このアクセスごとのオーバーヘッドはプロファイルの中で測定できるレベルになります。HotXLS は両方のファサードにバルクコールバック API を提供しており(読み取り用の ForEachCellForEachRow、書き込み用の WriteCellsWriteRows)、これはイテレーションをエンジンの内部に移し、コードには一度に丸ごと行を渡します

procedure TLedgerExport.FillRow(Sender: TObject;
  SheetIndex, Row, FirstCol, LastCol: Integer;
  var Values: Variant; var Skip: Boolean; var Cancel: Boolean);
begin
  if Row > FCount then
  begin
    Cancel := True;     // 書き込み全体を止める
    Exit;
  end;
  Values := VarArrayOf([FRows[Row - 1].Account,
                        FRows[Row - 1].PostedOn,
                        FRows[Row - 1].Amount]);
end;

// 数十万回のプロパティアクセスの代わりに 1 回のエンジン呼び出し
Sheet.WriteRows(1, 1, FCount, 3, FillRow);

コールバックの Skip フラグは、中断せずに行を変更しないまま残し、Cancel は処理を早期に終了させます。これは、ソースが読み進めるまで長さがわからないリーダーである場合に便利です。構築には WriteRows、保存には StreamingWrite を組み合わせれば、生成パスにはセルごとのホットスポットが残らなくなります

XLS ファサードにおける読み取り側のレバー

大きなレガシー .xls ファイルには独自のツールキットがあります。Open の前に _DisableGraphics := True を設定すると、描画レイヤーのパースを完全にスキップし、何年分も蓄積した図形や埋め込み画像を運ぶワークブックの読み込みを高速化します。この制約は厳格です。描画レイヤーはその後モデルから欠落するため、そのようなワークブックを保存すると、描画のないファイルが書き込まれます。このフラグは読み取り専用の分析ジョブのために取っておいてください。SetTempDir は BIFF ライターの一時ファイルをリダイレクトします。これは、既定の一時ファイル置き場にクォータがあったり低速なストレージ上にあったりするサーバーで重要になります。UseSharedFormulas は繰り返される数式本体を共有数式レコードにグループ化し、ある数式列が 6万行にわたって繰り返されるようなファイルを縮小します

XLS データに対する読み取りループには、注意を促す価値のあるインデックスの罠があります。防御的に処理すると作業が倍になり、見逃すと結果が壊れるからです。UsedRange はその FirstRowLastRowFirstColLastCol の境界を 0 始まりで報告しますが、Cells.Item[Row, Col] は 1 始まりです。使用範囲を歩くスキャンは、Cells.Item[Row + 1, Col + 1] のように、セルへのアクセス時に各座標へ 1 を加えなければならず、そうしなければ 1 セル分斜めにずれたグリッドを読むことになり、最後の行と列を静かに落とし、幻の最初の行を含めてしまいます。ForEachCell コールバックはこの不一致を完全に回避するため、シート全体をスキャンする際にはこちらを選ぶべき、もう1つの理由になります

読み込む前にファイルを探る

最も安価な大規模ワークブック操作は、そもそも避けたその操作です。両方のファサードにある GetSheetNames は、セルデータを読み込むことなくファイルのワークシートを一覧表示します。XLSX の実装は zip 内のワークブックマニフェストだけを読み、ワークブックインスタンスを明示的に未充填のままにしておき、XLS ファサードは最初のサブストリーム境界でスキャンを止めます。これにより、「このインポートジョブはどのシートを対象にすべきか」という事前チェックにふさわしいものになり、CanReadEncrypted は、失敗するとわかっている Open の試みの前に「これは暗号化されたコンテナか」という問いに答えてくれます

Delphi と HotXLS による未知の Excel ファイルのプリフライトフロー。GetSheetNames がセルデータをロードせずワークシートを一覧し、ゼロ以下の戻りコードはリストを空にして失敗を通知、CanReadEncrypted が破滅的な Open の前に暗号化コンテナを検出、その後にのみ完全なロードが走る
GetSheetNames と CanReadEncrypted が、セルデータを 1 つも解析する前に、どのシートを狙うべきか、コンテナーが読めるかどうかに答えます
Names := TStringList.Create;
Book := TXLSXWorkbook.Create;
try
  if Book.GetSheetNames('big-unknown.xlsx', Names) <= 0 then
    raise Exception.Create('cannot enumerate sheets');   // 失敗するとリストはクリアされる
  // 対象シートを選び、フルの Open に見合うかどうかを判断する
finally
  Book.Free;
  Names.Free;
end;

戻り値の慣習に注意してください。これらの探索用の関数は、0 以下の値で失敗を知らせ、出力リストを空にするため、特定の 1 つの成功値と比較するのではなく <= 0 でテストしてください

ジョブに合わせてアプローチのサイズを決める

大きなファイルを連続して生成する無人パイプラインについては、さらに 2 つの習慣が全体像を締めくくります。ワークブックオブジェクトは共有に対してスレッドセーフではありませんが、ワーカースレッドごとに独立したワークブックを 1 つ持つことを妨げるものは何もなく、これはバッチ変換をきれいに並列化します。そして出力がディスクではなく HTTP に向かう場合、TStream 版の保存オーバーロードは StreamingWrite と組み合わさり、大きなレスポンスが一時ファイルとして実体化することは決してありません。運用上の注意点が 1 つあります。ストリーム版の保存は現在位置から書き込み、巻き戻しは行わないため、ストリームをレスポンスフレームワークに渡す前に Position := 0 を設定してください。ストリーミング書き込みとバッチジョブの記事はそのサーバー側のパターンを発展させており、データベースエクスポートの記事は、これらのレバーがデータセット駆動のレポートのどこに収まるかを示しています

最後に、レポートファミリーごとに 1 つの最悪ケースのフィクスチャを保持し、CI でその時間を計測してください。文書生成における性能の劣化は、めったに自分から名乗り出てくれません。ループの中で追加されたスタイルや、フルの Open に置き換えられたプローブは、機能的には何も変えず、夜間バッチが単に 40 分長くかかるようになるだけです。代表的な 50万セルのフィクスチャに対するタイム計測付きテストは、そうしたドリフトを運用上のインシデントではなく、red build に変えてくれます

評価用ビルド、バルク生成のサンプルを含むデモプロジェクト、そして完全な API リファレンスは HotXLS Delphi Component のページで入手できます