コンテンツにスキップ

メモリとストリーミング

Spec: ISO 32000-2, §7.5.4 Evidence: Mixed evidence

大きな PDF だからといって、大量のメモリを必要とするべきではありません。このページでは、ドキュメントが大きくなってもプロセスのヒープを上限内に保つ NextPDF の仕組み、蓄積せずにディスクへストリーミングする箇所、そしてここでの「パフォーマンスバジェット」が何を意味するのか(見出し用の数値ではなく、検証される契約であること)を解説します。

PDF 形式は、ジェネレーターに大量のメモリ使用を強いるものではありません。クロスリファレンステーブルがすべての間接オブジェクトのバイトオフセットを記録するため、リーダーはファイル全体をメモリに置く必要がなく、ファイルへのランダムアクセスだけで済みます。ジェネレーターも同じ方法を取れます。オブジェクトが完成するたびに書き出し、それがどこに配置されたかだけを覚えておけばよいのです。逆に、最後の書き込みまでドキュメント全体がヒープに残り続ける場合、ページ数に比例してメモリが線形に増え、100 ページでは問題なかったレポートが 50,000 ページではプロセスを破綻させます。

バッチやワーカーのワークロードでは、これが安定したサービスと、負荷時に予測不能な形で破綻するサービスとの差になります。上限内に収まるメモリは、期待するだけの数値ではなく、設計として作り込むべき性質です。

  • ストリーミングライターは、メモリが ドキュメントごとに上限内 にとどまるよう設計されています。各ページは、確定するとすぐに出力へ書き込まれます。その後、そのバッファは解放されます。
  • そのままではオブジェクト数とともに増えていく管理情報、つまりクロスリファレンスのオフセットとページツリーの Kids 参照は、php://temp/maxmemory:0 で開いた一時ストリームに書き込まれ、PHP のヒープを埋めるのではなく即座にディスクへスピルされます。
  • 設計目標は ページあたり O(1) のヒープ です。ページを追加してもドキュメントの保持コストは増えません。これが、ライターの設計を方向づけるエンジニアリング上の目標です。
  • パフォーマンスバジェット は、ドキュメントシステムに実在する構造化された概念です。実時間の上限とピークメモリの上限を、検証される契約として表現します。これは義務の宣言です。ベンチマーク結果ではありません。
  • 具体的な数値は 生きたシグナル として扱われ、明示された方法のもとで測定されます。文章中に固定化され、いつの間にか古びてしまうことはありません。

ストリーミングライターの全体像は、あるひとつの決定から導かれます。書き出せるものは決して保持しない、ということです。

  1. Start page A single active cursor; no document-wide page graph in memory.
  2. Finalise page Page content + page object written straight to the output stream.
  3. Release buffer The finalised page buffer is dropped; the heap returns to baseline.
  4. Record offset to disk Xref and Kids entries go to php://temp/maxmemory:0 — immediate disk spill.
  5. Close Pages-tree root, Catalog, and trailer written once at the end.
ストリーミングライターのページ単位のサイクル。各ページは書き出されて解放され、増え続ける管理情報はディスクに裏付けられた一時ストリームへ送られるため、ヒープはページ数とともに増えません。

ディスクへのスピルという細部こそが要点です。PHP の php://temp は少量をメモリに保持し、しきい値を超えた場合にのみスピルします。ライターはそれらの一時ストリームを maxmemory:0 オプションで開きます。これにより、メモリ上のしきい値がゼロになるため、即座に スピルするよう強制されます。実際の効果として、定義上ドキュメントとともに増えるオブジェクト単位の管理情報が、ヒープに蓄積することは決してありません。蓄積される場所はディスク上であり、そこではサイズは制約になりません。このオプションがなければ、スピルする前にデフォルトのメモリ上ウィンドウが満たされる必要があり、上限内メモリという目標が最も重要な場面で損なわれてしまいます。

パフォーマンスバジェット はもう一方の側面であり、マーケティング上の主張ではなく、ドキュメントシステムの契約です。スキーマはバジェットを 2 つの上限付き整数として定義します。ミリ秒単位の実時間上限と、メビバイト単位のピーク常駐メモリ上限です。バジェットを宣言するレシピは、型付きシグネチャがコンパイラーで検証できる義務を宣言するのと同じように、検証可能な義務を宣言します。バジェットの価値は、それが小さいことではなく、明示され、強制される ことにあります。

このページは Evidence: Mixed evidence です。実際にエビデンスが 3 種類あるため、この混在は意図的なものです。

  • コードに裏付けられた仕組み。 src/Writer/Streaming/StreamingPdfWriter.php のストリーミングライターは、ページ単位の「書き出してから解放する」サイクルを文書化して実装し、xref と Kids のストリームを php://temp/maxmemory:0 で開いて即座のディスクスピルを強制することで、「オブジェクト数に関係なく PHP のメモリを上限内に保つ」ようにしています。このようにストリーミングし、シングルカーソルで、ツリーを保持しない設計は、ADR-001 に記録されたアーキテクチャ上の決定でもあります(レンダリングパイプラインが保持する状態は最大でも O(depth) であって、O(n) のノードではありません)。
  • 設計原則としてのバジェット。 performance_budget フィールドは、ドキュメントスキーマに実在する任意要素であり、明示的な上限を持つ { wall_ms, peak_mb } として定義されています。これは設計上、強制可能な契約です。
  • 生きたシグナルとしてのベンチマーク。 ADR-001 は、制御された大規模ドキュメントのピークメモリと実時間の数値が、明示された方法のもとで収集・記録されるべき経験的な目標 であって、文章中で主張される数値ではないことを明言しています。そのため、このページは仕組みと契約を述べ、具体的な数値についてはそれを測定する場所を指し示します。

この形式により、その目標は理想論ではなく現実的なものになります。クロスリファレンステーブルはオブジェクトごとのオフセットインデックスであり、その根拠は Spec: ISO 32000-2, §7.5.4 です。そのため、ジェネレーターは 次のことができます。つまり、 オブジェクトを完成させるたびに書き出し、そのオフセットだけを保持できます。上限内に収まるメモリは、ファイル形式に逆らうものではなく、それと整合するものです。

上限内に収まるメモリは、設定するフラグではなく、生成方法に属する性質です。各ドキュメントを確定して解放するバッチループは、実行全体を通してヒープを一定に保ちます。

<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use NextPDF\Core\DocumentFactory;
use NextPDF\Core\PdfFactory;
use NextPDF\Graphics\ImageRegistry;
use NextPDF\Typography\FontRegistry;
// Process-lifetime, shared once.
$factory = PdfFactory::new()
->withCompress(true)
->withDocumentFactory(new DocumentFactory(
new FontRegistry(),
new ImageRegistry(maxCacheBytes: 50 * 1024 * 1024),
));
// Per-document, created and released each iteration.
foreach ($invoiceBatch as $invoice) {
$doc = $factory->create();
$doc->addPage();
$doc->writeHtml($invoice->toHtml());
$doc->save($invoice->outputPath());
unset($doc); // the document model is not carried into the next iteration
}

レジストリを共有するのは、フォントと画像を一度だけ解析することがワーカーの要点だからです。ドキュメントは 共有されず、各回で解放されます。これにより、バッチのメモリ上限はバッチ全体ではなく 1 つのドキュメントによって決まります。

最もよくある誤解は、「上限内に収まるメモリ」をベンチマーク上の主張として扱い、引用できるメガバイト単位の数値を期待してしまうことです。それは、ここで述べていることを逆に捉えています。ここでの保証は 構造的 なものです。ライターは、ページを追加してもドキュメントの保持コストが増えないように作られています。具体的なピーク値はページの内容、フォント、画像によって異なり、測定方法が添えられて初めて意味を持ちます。だからこそ、それはこの本文ではなくベンチマークに属します。

2 つ目の落とし穴は、php://temp がすでに守ってくれていると思い込むことです。確かに守ってくれます。ただし、それはデフォルトのメモリ上ウィンドウが満たされた後だけです。即座のスピルを実現するのは maxmemory:0 オプションです。この細部こそが仕組みです。これがなければ、この性質は本来対象とする大規模ドキュメントのもとで成り立ちません。

このページでは、ストリーミングの仕組みとパフォーマンスバジェットの意味を解説します。実測されたピークメモリやスループットの数値を述べることは ありません。それらは宣言された方法のもとでベンチマークの規律によって生成されるものであり、ADR-001 は経験的な数値をその測定に明示的に委ねています。「ドキュメントごと」に上限内とは、単一ドキュメントの内容に関係なく一定であるという意味ではありません。大きな埋め込み画像を多数含むページは、それらの画像の分のコストが依然としてかかります。増えないのは ページ単位の管理情報 と保持されるページグラフです。すべての生成パスがストリーミングライターであるわけではありません。どのパスがストリーミングし、どのパスがバッファリングするかは、この概要ではなく、コードとパイプラインの構造によって決まります。ここで説明した仕組みは、このページのレビュー日時点で正確です。権威ある情報源は、コアリポジトリの src/Writer/Streaming/ と ADR-001 です。

ストリーミングと上限内メモリの設計は Core の特性です。エディションによってこれが変わることはありません。

Bounded-memory streaming writer — edition availability
Edition Availability
Core Core はストリーミングしてディスクへスピルするライター設計を提供します。
Pro Pro は同じ上限内メモリのライターを継承します。追加されるのは機能であって、異なるメモリモデルではありません。
Enterprise Enterprise は同じ上限内メモリのライターを継承します。追加されるのは機能であって、異なるメモリモデルではありません。
  • 上限内に収まるメモリ — ページを追加してもドキュメントの保持が追加のヒープを消費しないという設計上の性質(ページあたり O(1) の目標)。
  • ストリーミングライター — ドキュメント全体を保持するのではなく、各ページを出力へ書き出してそのバッファを解放するライター。
  • php://temp/maxmemory:0 — 即座にディスクへスピルするよう強制された PHP の一時ストリーム。増え続けるオブジェクト単位の管理情報に使用されます。
  • パフォーマンスバジェット — 構造化されたドキュメントの契約。実時間の上限とピークメモリの上限を、明示して検証可能にしたもの。
  • 生きたシグナル — 文章中に埋め込まれた固定の数値ではなく、明示された条件のもとでその方法とともに報告される測定値。