技術記事

DelphiでのHotXLSインクリメンタル数式再計算

DelphiおよびC++Builder向けのネイティブなExcelライブラリであるHotXLSは、TXLSXWorkbook.Recalculate を介してインクリメンタル数式再計算を実行します。最初の呼び出しで数式依存関係グラフを構築し、すべての数式セルを評価します。それ以降の呼び出しでは、最後の再計算パス以降に書き込みが行われたセルによって影響を受けるセルのみを、トポロジカル順序に従って1回のスイープで再評価します。その計算コストは、ワークブック全体のサイズではなく、ダーティ(変更あり)なセルの数に比例します

この設計上の意思決定により、前提条件の変更に対して財務モデルがミリ秒単位で応答するか、あるいは数秒間フリーズするかの違いが生まれます。少数の入力セルが下流にある何千もの数式を養うレポートを生成する場合、この記事では、依存関係グラフの役割、インクリメンタル再計算から除外される(毎回再評価される)関数、および無限ループに陥らずに循環参照が報告される仕組みについて詳しく説明します

1つのセルを変更するだけで10万もの数式が再計算される理由

単純な数式エンジンは「誰が誰に依存しているか」を記憶しないため、任意のセルが編集された後の唯一の安全な動作は、すべてを再計算することです。さらに悪いことに、「数式Aが数式Bを参照している場合、その場でBを評価する」という古典的な再帰的戦略は、キャッシュされた値を無視して、参照されたセルを無条件に再評価します。n個の数式がそれぞれ前の数式を参照するチェーンでは、1回の完全な再計算パスあたりO(n²)の評価コストがかかり、循環参照が発生すると再帰がクラッシュに繋がります。カスケードモデルを再帰型エバリュエーターに組み込んだことのあるスプレッドシート開発者であれば、誰しもこれら2つの失敗モードに直面したことがあるでしょう

Excel自身は数シート前に、数式セルの順序を維持し、編集時に少数のセルのみをダーティとしてマークしてエンジンがその影響を受ける末尾のみを走査する「計算チェーン(Calculation Chain)」によってこの問題を解決しました。HotXLSはこれと同じアイデアを、コンパイル済みの数式ツリーから一度構築し、再計算パス間で再利用する明示的な依存関係グラフとして適用しています。重要なのはスマートさではなく、再計算のコストがワークブックのサイズではなく、編集した箇所のサイズに追従すべきであるという点です

依存関係グラフが編集処理を1回のパスに変える仕組み

HotXLS' の依存関係グラフは、各数式セルに1つのノードを割り当て、先行ノード(参照元)から依存ノード(参照先)へエッジを張ります。コードがセルの値を書き込むと、ワークブックはそのセルをダーティとして記録します。Recalculate が実行されると、ダーティ状態がエッジに沿って下流のすべての数式に伝播し、Kahnのアルゴリズムを使用して、ダーティな部分グラフがトポロジカル順序で正確に一度だけ評価されます。数式はその先行ノードよりも先に評価されることはないため、各ノードは1回評価されるだけで済み、これが再計算パスをO(dirty)にする理由です

また、トポロジカル順序は再帰の問題を根本から解決します。再計算パスの実行中、エンジンは専用のモードに切り替わり、他の数式セルへの参照は、そのセルを再評価するのではなく、キャッシュされている値を直接読み取ります。評価順序が保証されているため、そのキャッシュは常に最新です。これと同じ仕組みにより、参照サイクル(循環参照)が無限の再帰を引き起こすのを防ぎます。再計算パス内では、隣接するセルのエバリュエーターが再入(リエントラント)されることはありません

var
  Book: TXLSXWorkbook;
  Inputs, Model: TXLSXWorksheet;
begin
  Book := TXLSXWorkbook.Create;
  try
    Inputs := Book.Sheets.Add('Inputs');
    Model  := Book.Sheets.Add('Model');

    Inputs.Cells[2, 2].Value := 0.05;                 // growth assumption
    Model.Cells[2, 2].Formula := 'Inputs!B2*1000';    // XLSX formulas take no leading '='
    Model.Cells[3, 2].Formula := 'B2*(1+Inputs!B2)';
    // ... thousands more rows cascading off the same assumption ...

    Book.Recalculate;                 // first call: builds the graph, full evaluation

    Inputs.Cells[2, 2].Value := 0.07; // one edit marks one cell dirty
    Book.Recalculate;                 // second call: only the downstream chain runs
  finally
    Book.Free;
  end;
end;

各計算結果はセルのキャッシュされた Value に格納されるため、Recalculate が戻った後は、他のセルを読み取るのと同じ方法で出力を取得できます。レポート生成のループにおけるパターンは、まさに上記のコードの通りです。すなわち、モデルを一度ロードまたは構築し、その後は少数の入力セルへの書き込みと Recalculate の呼び出しを交互に行い、実際に変更の影響を受ける数式に対してのみ計算コストを支払います

毎回のパスで強制的に再計算されるExcel関数

HotXLSは、NOWTODAYRANDOFFSET、および INDIRECT を揮発性(Volatile)として扱います。これらを含む数式は、上流に変更があったかどうかにかかわらず、毎回の Recalculate パスで再評価されます。最初の3つが揮発性である理由はExcelと同じで、結果が他のセルではなく評価された瞬間に依存するためです。OFFSETINDIRECT が揮発性である理由はより繊細です。これらが読み取るセルは実行時に計算されるため、グラフ構築時に静的にどことエッジを結ぶべきかを判断できないからです

同じ保守的なルールは、グラフビルダーが単一の矩形(領域)に特定できない参照にも適用されます。複数の領域を持つ名前付き範囲を経由する数式や、外部のワークブックを参照する数式も、同様に揮発性に格下げされ、毎回のパスで再評価されます。この方針は意図的なものです。不要な再評価は少しの時間を消費するだけですが、依存関係のエッジが欠落すると、出荷されたレポートに古い値が警告なしに残ってしまい、これははるかに深刻な失敗となるからです。モデルがワークブックレベルの名前(定義された名前)に依存している場合、単一領域の名前がどのように解決されるかについては、姉妹記事の定義された名前とシートをまたぐ数式を参照してください。それらは正常にグラフに参加します

実用的なガイダンスは直接的です。大規模なモデルの頻繁に実行されるパス(ホットパス)は、グラフがその役割を果たせるように、通常のセルおよび範囲参照に保ち、OFFSETINDIRECT は動的なアドレス指定がどうしても必要な少数の場所に限定してください。数千の揮発性数式を含むモデルでは、編集の規模がどれほど小さくても、毎回のパスでその数千がすべて再実行されます。これは、Excelユーザーが「キー入力のたびに再計算が走る」ワークブックでよく経験する動作そのものです

HotXLSが循環参照を報告する仕組み

TXLSXWorkbook.Recalculate は、正常に計算が完了した場合は lxOk を返し、参照サイクル(循環参照)を検出した場合は lxErrorRef を返します。サイクルのメンバーはトポロジカルソートの実行中に特定されます。これらは Kahnのアルゴリズムが決して解放できないノードであり、無限ループに陥るのを防ぎます。これらのキャッシュされた値は以前の状態のまま維持されますが、サイクルの外にあるすべての数式はトポロジカル順序に従って正常に評価されます。呼び出し元は、ハングアップする代わりに明確なエラーコードを受け取ります

case Book.Recalculate of
  lxOk:
    SaveReport(Book);
  lxErrorRef:
    // a reference cycle exists; cycle members kept their previous
    // cached values and everything outside the cycle is up to date
    LogWarning('Circular reference detected - review model inputs');
end;

どのセルがサイクルを形成しているかを特定するのはデバッグ作業であり、それには数式評価トレーサーが最適です。疑わしい数式をトレースすれば、自身に戻ってくる参照チェーンが段階的に可視化されます。実際のモデルにおけるサイクルは、ほぼ常に作成者のミス(SUMの範囲に誤って集計行自体を含めてしまうなど)であるため、再計算時に明確なエラーコードが返される仕様はまさに望ましい動作です

配列数式、ダーティトラッキング、およびグラフが再構築される条件

CSE配列数式は、セルごとに1つではなく、固定された矩形領域全体に対して1つのノードが割り当てられます。ルートの数式はパスごとに1回評価され、結果のマトリックスが各メンバーセルに直接書き込まれます。そして、固定された範囲内のいずれかのセル(左上のアンカーセルだけでなく)を参照する数式は、そのルートノードから依存関係のエッジを受け取ります。スカラー値の結果は、Excelのレガシーな配列数式セマンティクスが規定するように、矩形領域全体にブロードキャスト(複製)されます

ダーティトラッキングは通常のプロパティセッターにフックされているため、コードの記述方法を変更する必要はありません。セルの Value を書き込むとワークブックに通知され、その依存ノードがダーティとしてマークされます。新しい Formula を割り当てることは構造的な変更であるため、グラフ全体が古い(Stale)とマークされ、次の Recalculate の実行時に評価の前にグラフが再構築されます。シートの追加、削除、または移動も、ノードの特定にシートインデックスがエンコードされているため、グラフを無効化します。グラフがアクティブでない場合(Recalculate を一度も呼び出さないワークブック)は、これらのフックのオーバーヘッドは代入ごとに1回のnilチェックだけで済むため、通常の読み書きの処理能力には影響しません

率静な制限として、グラフはセル間の依存関係を追跡するため、OnUserFunction を介して登録されたユーザー定義関数は、その引数に提供されるセルが変更された場合に、他の数式と同様に再評価されます。エンジンをそのように拡張している場合は、姉妹記事のHotXLS数式エンジンにおけるカスタム関数で、コールバックの仕様と引数の値がどのように渡されるかについて説明しています

インクリメンタル再計算は、数式計算機、定義された名前、およびそれが高速化するインポート/エクスポートパイプラインと並んで、DelphiおよびC++Builder向けのHotXLS Delphi Excel ComponentのXLSXエンジンに標準搭載されています。DelphiまたはC++Builderアプリケーションが、価格表、連結ワークブック、カスケードレポートなどの動的なモデルを維持する場合、Recalculate の有無は、ワークブック全体を再計算するか、編集された部分のみを再計算するかの違いを生みます