DelphiおよびC++Builder向けのネイティブExcelライブラリであるHotXLSは、3段階のロード処理(シートXMLのシリアル解凍、並列パース、およびその後の小規模パーツのシリアル読み込み)を介して、複数のスレッドでXLSXワークシートを並列パースします。この機能の最初のリリースでは、Delphiのデフォルトのメモリマネージャのロックによってワーカースレッドの処理が直列化されてしまい、12〜25%の向上にとどまりました。セルあたりのヒープ割り当て数を約20から9.1へと削減したことで、8スレッドにおいて並列パースの速度が1.90倍に向上しました。本記事では、測定結果、誤ったアプローチ、および実際に効果のあった2つの修正方法について詳しく解説します
HotXLSはどのようにXLSXワークシートを並列パースするのか?
HotXLSはOpenメソッドの処理を3つのフェーズに分割しており、中間のフェーズのみがワーカースレッド上で実行されます。その理由はZIPコンテナの制約にあります。ZIPアーカイブは1つの共有入力ストリームと1つのインフレート(展開)状態マシンで構成されており、その状態マシンを同時に2つのスレッドから読み取ることはできません。インフレート処理は本質的にエントリごとに直列に実行されるため、排他ロックでラップしてもオーバーヘッドが増えるだけで実質的にシリアル実行の再現になってしまいます。そのため、フェーズAでは、シングルスレッドの状態で各ワークシートのXMLを個別のTMemoryStreamに解凍します。ベンチマークファイルでは、8枚 of シートデータに対してこの処理は約4ミリ秒で完了したため、ボトルネックとは程遠い状態です。フェーズBでは、ワーカープール上で各シートに対してParseWorksheetXmlを実行します。ロード時間のほぼすべてがこのフェーズで消費されます。フェーズCでは、コメント、図形、グラフ、テーブルなどの小さなパーツの読み取りのために、再び直列にZIPアーカイブにアクセスします
ワーカープール自体は、意図的にシンプルな構成に設計されています。ワーカーはInterlockedIncrementを使用して共有カウンターからジョブインデックスを取得するため、サイズの異なるシートであってもスケジューラなしで自然に負荷が分散されます。スレッド数はmin(シート数, CPUコア数)に設定され、最初のワーカーで発生した例外はAcquireExceptionObjectによってキャプチャされ、スレッドの合流後にメインスレッドで再スローされます。ジョブ数が0または1個の場合は、ディスパッチャはシリアル実行のループにフォールバックします。この並列機能は、TXLSXWorkbookの2つのプロパティで制御できます。ParallelParseはプール全体の有効・無効を切り替え、ParallelParseThreadsはスレッド数を制限します(0は自動設定を意味します)。複数シートを含むワークブックは、テンプレートワークシートを何十回も複製して作成されたファイルを含め、この並列化のメリットを大きく受けることができます
var
Book: TXLSXWorkbook;
begin
Book := TXLSXWorkbook.Create;
try
Book.ParallelParse := True; // enable the parallel worker pool
Book.ParallelParseThreads := 0; // 0 = auto: min(sheets, CPU cores)
if Book.Open('quarterly-ledger.xlsx') <= 0 then
raise Exception.Create('open failed');
// ... read cells as usual; the workbook is fully materialized ...
finally
Book.Free;
end;
end;
なぜDelphiでスレッドを追加するとXLSXのパースが遅くなるのか?
原因は、Delphiのデフォルトのメモリマネージャがグローバルロックを使用してヒープを保護しているのに対し、ワークシートのパース処理はセル、Variant、WideStringなどの割り当てが数百万規模で発生するメモリ集約的な処理であるためです。ヒープにアクセスするすべてのワーカーがそのグローバルロックのキュー(待機列)に並ぶため、ソースコード上は独立して並列動作しているように見えるスレッドも、実際にはほぼ1つずつ順番に実行されることになります。最初のベンチマーク測定で、この問題が極めて明確に浮き彫りになりました。シートあたり5,000行×4列のデータを持つ8シートのワークブックを使用し、Win64環境下のi5-11600K(6コア、12スレッド)で測定したところ、並列Openによる処理速度の向上は、事前予測であった最低40%に対してわずか12〜25%にとどまりました。スレッド数を2、3、4、6、8と増やしてもパフォーマンスのグラフはほぼ横ばいであり、さらに詳細に計測した実行結果では、2スレッド時の処理速度は直列実行時よりも26%低下していました。これは、2つのスレッドが競合するロックを交互に奪い合う典型的な現象です
3つの測定結果がこの診断を決定づけ、それまでの開発者の直感を覆しました。第1に、ごく小さなファイル(1行のみのシートが8枚)の読み込みが1.2ミリ秒で完了したことで、パース処理がOpenの処理時間のほぼ100%を占めており、隠れた固定コストなどの原因はないことが証明されました。第2に、純粋なアロケーション(メモリ確保)負荷のみをテストするマイクロベンチマークにより、Delphiのメモリマネージャが並列数に応じてスケールダウンしていることが明らかになりました。同じ総量である200万回のオブジェクトおよびAnsiStringのアロケーションを行ったところ、8スレッド時の実行速度は1スレッド時よりも60%低下しました。一方で、DelphiのメモリマネージャではなくCOMのBSTRアロケータを使用するWideStringのヒープに対する負荷テストでは、実行速度は3.7倍にスケールしました。HotXLSが全体でWideStringを使用していたという開発経緯は、結果としてパフォーマンス上の幸運な要因となりました。第3に、GetProcessTimesによる計測結果から、並列でのOpen実行中のCPU時間が実時間とほぼ等しいことが判明しました。論理上は8本のスレッドが動作しているのに対して、消費されたCPUパワーはスレッド1.3本分程度でした。ワーカーは活発に動作していたのではなく、メモリマネージャの競合パスの中でロック解放を待ち、処理能力を発揮できない状態でスリープしていました
この実践的な教訓は、スプレッドシート処理以外にも広く当てはまります。Delphiのワークロードにおいて大量のメモリ割り当てが発生する場合、割り当て頻度(アロケーションレート)自体を下げない限り、スレッド数を増やしても効果は得られず、かえって状況を悪化させることがあります。この修正を行う以前、私たちはParallelParseThreadsを調整するユーザーに対し、アロケーションが集中するファイルではスレッドを増やしてもほとんど効果がないという現実を率先して伝えていました
セルあたり20回のヒープ割り当てはどこで発生しているのか?
SetMemoryManagerを使用して組み込んだ集計用ラッパーが、この疑問に正確な答えを示しました。すなわち、セルあたり約20回のDelphiメモリマネージャによる割り当てが発生しており、そのうち287万回は32バイト以下の極めて小さなメモリ領域の確保でした。原因はセルオブジェクト自体ではありませんでした。TXMLScaner.GetTokenValueが呼び出されるたびに新しいAnsiStringを生成しており、これがセルあたり約15〜20回呼び出されていました(要素名、属性名、属性値、テキストコンテンツでそれぞれ1回ずつ)。さらに、RTLのUTF8ToWideStringの内部処理により、すべての文字変換で一時的なUnicodeString中間データが生成されていました。セルオブジェクトの確保は合計で16万回(全体の約8%)にすぎず、これにより当初の計画であった「セルオブジェクトプールを構築する」というアイデアは即座に却下されました。数値データは、プールを作成してもコストに見合わないことを明確に示していたためです
var
OldMM, NewMM: TMemoryManagerEx;
AllocCount, TinyCount: Int64;
function CountingGetMem(Size: NativeInt): Pointer;
begin
AtomicIncrement(AllocCount);
if Size <= 32 then
AtomicIncrement(TinyCount); // the small-object churn we care about
Result := OldMM.GetMem(Size);
end;
// install before Open, restore afterwards
GetMemoryManager(OldMM);
NewMM := OldMM;
NewMM.GetMem := CountingGetMem;
SetMemoryManager(NewMM);
この10分間の診断アプローチは、あらゆるDelphiのパフォーマンス調査で参考にすべき有用な手法です。サイズ別のバケットごとにアロケーション数を集計する仕組みは構築コストがほとんどかからず、メモリマネージャの負荷が実際にどこで発生しているかを突き止めることができます。今回のケースでは、オブジェクトモデル側ではなくXMLスキャナ内のRTLレベルの2つの実装習慣が原因でした。プロファイラはパーサー全体の処理を指摘し続けましたが、アロケーションラッパーは2行の具体的な原因コードを特定しました
修正:トークンのインターン化と中間アロケーションゼロのUTF-8デコーダー
XMLリーダーに対する2つのピンポイントな変更により、パーサー自体の構造を変更することなく、セルあたりのメモリ割り当て回数を半分以下に削減しました。第1の変更は、要素名のインターン化(共有化)です。ワークシートのXMLデータには、row、c、v、r、t、s、および少数の属性名といったごく限られた名前が何度も繰り返し出現します。InternTokenNameは、以前に出現した名前を保持する64スロットのキャッシュを管理し、スキャナの構築バッファとキャッシュされたエントリをTokenEqualsAnsiで直接比較します。このバイト比較処理では新たなメモリ割り当ては発生しません。キャッシュにヒットした場合はキャッシュされたAnsiStringを返します。ここでデータ型の選択が重要になります。AnsiStringは参照カウント方式であるため、キャッシュされたインスタンスを返しても発生するコストは参照カウントのインクリメントが1回だけであり、ヒープへのアクセスは発生しません。WideStringには参照カウントがないため、値の代入のたびにSysAllocStringが実行され、WideStringをインターン化してもアロケーションは削減できません。インターン化処理は、参照カウント方式の文字列型に対してのみ効果を発揮します
function TXMLScaner.InternTokenName: AnsiString;
var
Slot: Integer;
begin
Slot := TokenHash mod 64;
if TokenEqualsAnsi(FInternNames[Slot]) then
Result := FInternNames[Slot] // refcount++ only, no allocation
else
begin
Result := GetTokenValue; // materialize once, then cache
FInternNames[Slot] := Result;
end;
end;
第2の変更は、セルのテキストデータに対する処理です。従来のコードでは、AnsiStringトークンを構築し、それをUTF8ToWideStringに渡し、そこで一時的なUnicodeString中間データを構築し、最終的にセルに格納するWideStringへ変換していました。これでは、実際のデータ確保の前にテキストトークンあたり2回のDelphiメモリマネージャによるメモリ割り当てが発生してしまいます。代替となる処理であるXmlUtf8ToWide(TokenPtr, TokenLen)は、スキャナのバッファからデータを直接読み取る2パス構成の純粋なPascal製のUTF-8デコーダーです。1パス目でUTF-16としての文字数をカウントし、2パス目で1回だけアロケーションされたWideStringへデータをデコードします。これにより、テキストトークンあたりの実質コストはCOMアロケーションが1回のみとなり、Delphiメモリマネージャによる割り当てはゼロになりました。慎重を期すための動作上の注意点として、破損した不正なUTF-8シーケンスが入力された場合、新しいデコーダーはRTLのように代替文字に置換するのではなくバイトデータをそのまま通します。これは破損ファイルの劣化動作にのみ影響します。正常な入力データに対しては、変換結果は完全に同一のバイトになります。XMLの文字実体参照(エンティティ)は、スキャナ側でトークンバッファへの格納時にすでにUTF-8に解決されているため、デコーダーまで届くことはありません
アロケーション削減の効果と、並列パースが機能しないケース
この2つの修正によってセルあたりのメモリ割り当て回数は約20回から9.1回に削減され、並列処理の測定数値も理論予測の通りに向上しました。同じ8シート・5,000行のベンチマーク測定を同一の6コア12スレッドのマシンで実行したところ、8スレッド時の性能向上は14%から47.4%に改善し、シリアル実行に対して1.90倍の高速化を達成しました。2スレッド時の結果はシリアル比で26%低下していた状態から23.6%向上へと改善し、測定されたCPU利用率は1.0倍から2.2倍に上昇しました。メモリ割り当てが減少したことで、シングルスレッドで動作するシリアル実行の処理速度も約3%向上しました。残りの約9回のアロケーションは、約半分がセルオブジェクトそのものであり、残りの半分はセルの格納コンテナの拡張コストなどです。アロケーション数を測定した結果、これ以上の最適化は効果が薄いと判断して作業を終了しました。将来的にさらなる改善が必要になった場合に備え、呼び出し箇所ごとにサンプリング可能なメモリマネージャの集計ツールはいつでも使用できる状態に維持しています
最適化の成果と同様に、制限事項についても明確にしておく必要があります。HotXLSはワークシート単位で並列処理を実行するため、1つの巨大なシートのみを含むワークブックの場合は、ParallelParseThreadsの設定値にかかわらず常にシングルスレッドでパースされます。そのような構造のファイルに対しては、ワークブック全体のデータをメモリ上に展開しないストリーミング型ダイレクトリーダーを使用する方が優れたアプローチです。また、処理時間の多くがフェーズC(図形、グラフ、コメントなど)の読み込みに費やされるファイルについては、そのフェーズが設計上直列に実行されるため並列化のメリットが少なくなります。データ量が少ない小さなファイルについては、スレッド生成のコストを考慮するとマルチスレッド化する価値はありません。そのため、ディスパッチャはジョブ数が少ない場合に自動的にシリアル実行を選択します。さらに、メモリマネージャの性能の壁自体が消滅したわけではなく、限界値が引き上げられたにすぎません。セルあたり9.1回のアロケーションであっても、依然としてグローバルロックがワーカースレッドの足を引っ張っています。これが、8スレッド時でも4倍ではなく1.90倍の高速化にとどまっている理由です。スタイル、オブジェクトプール、一括行コールバックなど、ファイル読み書き時間を短縮するための広範なアプローチについては、Delphiでの大規模ワークブックのパフォーマンスのガイドを参照してください
並列XLSXパース、ParallelParseおよびParallelParseThreadsプロパティ、そしてここで説明したメモリ割り当てを極限まで削減したXMLリーダーは、Excelのオートメーション操作を必要とせずDelphiおよびC++BuilderからネイティブにXLS、XLSX、ODSを読み書きできるHotXLS Delphi Excel Componentに標準実装されています