記事一覧

gpdf が他の Go PDF ライブラリより 10〜30 倍速い理由

1 ページ 13 µs、100 ページレポート 683 µs。チューニングではなく 3 つの設計判断の積。実際のコードパスで説明する。

TL;DR

gpdf は 1 ページ 13 µs、4×10 の請求書テーブル 108 µs、100 ページのレポートを 683 µs で生成する。次に速い jung-kurt/gofpdf は同じ 100 ページを 11.7 ms — 約 17 倍遅い。これはチューニングの差ではない。3 つの設計判断が積み重なった結果だ。

  1. 単一パスレイアウト。 Builder API と PDF コンテンツストリームの間に中間 AST を持たない。
  2. ホットパスの具象型。 レイアウトループ内に reflection も interface{} も仮想ディスパッチもない。
  3. cmap を一度だけ解決する TrueType サブセッタ。 グリフごとでも、ページごとでもない。一度だけ。

1 つだけでも 2〜3 倍。3 つ重ねて一桁の差になる。

この記事ではその数字を生むコードパスをそのまま追う。ベンチマークは _benchmark/benchmark_test.go に公開してある。クローンして、自分のマシンで回して、数字が食い違ったら issue で教えてほしい。

最初にバイアスを開示する。私たちは gpdf のチームだ。「私たちの方が速い」を正直に言い換えると「私たちは違うトレードオフを取った」であって、面白い問いは その速さのために何を捨てたか のほう。後半はその話をする。

そもそも「速い」とは

アーキテクチャの前に、説明しようとしているスコアボードを (Apple M1, Go 1.25, CGO 無効, -benchmem 有効):

ワークロードgpdfgofpdfgo-pdf/fpdfsignintech/gopdfMaroto v2
単一ページ (Hello World)13 µs132 µs135 µs423 µs237 µs
4×10 の請求書テーブル108 µs241 µs243 µs835 µs8,600 µs
100 ページレポート683 µs11,700 µs11,900 µs8,600 µs19,800 µs
CJK の複雑な請求書133 µs254 µsn/a997 µs10,400 µs

説明する前から見える 2 つの形がある。ページ数が増えるほど 差が広がる (Hello World で 10 倍、100 ページで 17 倍)。複雑度が増すほど 差が広がる (テーブル単独で gpdf 108 µs、Maroto の gofpdf バックエンド経由で 8.6 ms)。

どちらの形も原因は同じ。gpdf のレイアウトループは共通パスで割り当てを行わない から、要素 1 つあたりのコストがほぼ一定。なぜそうなっているのかを次から説明する。

念のための但し書き。多くの PDF ワークロードでは絶対速度の意味は思うほど大きくない。1 ページのレシートだけなら、このテーブルの現役ライブラリはどれもリクエストパス上で生成できる。差が効いてくる閾値は「100 通の請求書をキューに積まずに同期生成できるか」のあたりから。

決定 1: 中間 AST を作らない

一般的な PDF Builder ライブラリはこう動く:

builder API → ドキュメントツリー (AST) → レイアウトパス → シリアライザ → バイト列

真ん中の ドキュメントツリー の段階が問題だ。.Text() ごとにノードを割り当て、.Row() ごとにコンテナを割り当てる。レイアウトパスがツリーを歩いて位置を計算し、シリアライザがもう一度ツリーを歩いてバイトを書く。3 パス・3 回の割り当て・3 回の CPU キャッシュ往復。

gpdf にはこの段階 2 がない。Builder はレイアウトコンテキストに直接書き、レイアウトコンテキストはコンテンツストリームに直接書く。1 パス

テキスト要素の実際のコードパスがこれ (template/col_builder.go から長さ調整):

func (c *ColBuilder) Text(s string, opts ...TextOption) {
    opt := c.resolveOptions(opts)
    box := c.currentBox()
    w := c.measureText(s, opt)
    h := opt.FontSize.Pt() * opt.LineHeight
    c.writer.BeginText()
    c.writer.SetFont(opt.Font, opt.FontSize)
    c.writer.MoveTo(box.X, box.Y-opt.FontSize.Pt())
    c.writer.ShowString(s)
    c.writer.EndText()
    c.advance(w, h)
}

ノードはツリーに積まれない。位置は遅延されない。writer は *pdf.Writer で、その中の io.Writer (通常は bytes.Buffer) に対して BeginText / MoveTo / ShowString が即座に BT Td Tj ET の PDF 演算子を書き出す。

gofpdf の同じ論理操作と比較する。gofpdf は page オブジェクトに操作のスライスを持つ。SetXY + Cell を呼ぶたびにそのスライスに追記される。最終的に Output (か OutputFileAndClose) がスライスを歩いてバイトを吐く。セルあたり 2 割り当て (operation 構造体と文字列コピー) + 追加の 1 パス。

1 ページ 40 行 × 100 ページで、gpdf が行わない割り当てが 4,000 個発生する。

単一パスの痛いところ

当然の疑問: バイト出力を始めるより前に最終レイアウトを知る必要がある要素はどうするのか。ページ番号入りヘッダ。ページをまたぐテーブル。本文最終行の下にアンカーするフッタ。

答えは 2 つ。1 つ目、バッファリングの単位は ドキュメントではなくページ。ページは数十 KB 程度の有界ユニット。次の AddPage() が走ると、現ページのコンテンツストリームが確定 (Length, Filter, オフセット) され、xref エントリが書かれ、ページバッファはリセットされる。メモリの最大消費は O(1 ページ分)。

2 つ目、本当にグローバルな要素 ("Page 3 of 27" のような) については、その 範囲だけ を fix-up パスに遅延させる。残りの内容はすでにストリームに書かれている。fix-up は短い "deferred-reference" マーカーのリストを歩いてパッチする。ここはコードベースで AST に近いコストを払う唯一の場所で、実際に必要な範囲にしか 払わない。

代わりに捨てたもの: ノードツリーに対する任意の後処理ができない。「bold: trueText ノードだけ全部並び替える」ようなプラグインは書けない。ノードツリーがないからだ。この形の API が必要なら Maroto v2 を選ぶべきだ。

gpdf が対象とする用途にはこのトレードオフが正しいと考えている。PDF の大半は左から右・上から下に、構築時点で決まるレイアウトで生成される。少数派のために AST を抱え続けるコストを、多数派が全ページで払っている。その比率を入れ替えた。

決定 2: ホットパスに reflection も interface も置かない

書いて面白い話ではないが、プロファイルで見ると残り半分の速度差はここから来る。

gofpdf の CellFormat シグネチャ:

func (f *Fpdf) CellFormat(w, h float64, txtStr, borderStr string,
    ln int, alignStr string, fill bool, link int, linkStr string) { ... }

これは問題ない。Maroto のコンポーネントツリーを見る。Row[]Component を持ち、Component は interface。レイアウト操作のたびに仮想ディスパッチ: component.Render(ctx)TextSpacer が入った 1 つの Col で 3 回のディスパッチ。100 ページ × 30 行 × 3 コンポーネントで 9,000 回。

1 回あたり Go の interface ディスパッチは 2〜3 ns で、単独では罪ではない。ただし interface を通すことで、コンパイラはボックス化された値をヒープに置く必要がある — Go のコンパイラが常に行ってくれるわけではない devirtualization がない限り、interface 越しにスタック割り当てはできない。コストはディスパッチ自体ではなく、それを食わせるための割り当て。

gpdf のレイアウトエンジンは具象構造体を使う:

type RowBuilder struct {
    doc    *Document
    parent *pageState
    spans  [12]int
    cols   [12]ColBuilder  // 値配列。ポインタでも interface でもない
    n      uint8
}

type ColBuilder struct {
    row    *RowBuilder
    span   int
    cursor document.Point
    writer *pdf.Writer
}

cols はグリッドの最大 (12) にサイズされた値配列。ヒープ割り当てなし。行がカラムを反復するときに interface ディスパッチもなし。writer のポインタを Builder が持つ構造で、writer は Builder ツリーを知らない

コールバックパターン (r.Col(4, func(c *ColBuilder) { ... })) は偶然ではない。プロトタイプした他の形 (チェーン可能な struct 返し API、Component interface のボックス化ツリー) は全部遅かった。このクロージャのゼロ割り当ては ColBuilder が呼び出し側のポインタ引数経由で取られる値で、クロージャ自体が大半の場合 escape analysis でスタックに載ることによる。

これが効いたとわかるところ

gpdf で go test -run=XXX -bench=BenchmarkSinglePage -memprofile=mem.out を回す:

BenchmarkSinglePage-8   91270   13120 ns/op   8321 B/op   52 allocs/op

1 つの PDF ページ全体で 52 割り当て。そのほぼ全部が初期ページバッファ、フォントメトリクス参照 (フォントごと 1 回、グリフごとではない)、最後の bytes.Buffer 成長。レイアウトループはゼロ割り当て — プロファイルを見ればわかる。

gofpdf で同じページ:

BenchmarkGofpdfSinglePage-8   7500   132400 ns/op   71200 B/op   430 allocs/op

430 割り当て。大半が operation スライスとそれを埋める文字列コピー。割り当てが約 8 倍あれば、GC を含めた実行時間の差が約 10 倍になるのは自然な話になる。

代わりに失ったもの

ホットパスのエルゴノミクスがゼロということは、拡張ポイントが少ない ということ。gpdf のレイアウトに組み込める独自要素型 — Maroto の Component を実装するのと同等のこと — は書けない。実装すべき interface がない。代わりに用意しているのは template.WithWriterSetup()。これは PDF writer へのフックで、カスタムアノテーション、PDF/A メタデータ、暗号化などを注入できる。レイアウトレベルの拡張は、ユーザーが呼ぶのと同じ Builder メソッドを呼ぶヘルパーとして書くことになる。

拡張ポイントが少ないのは本物のコストだ。今の判断としては釣り合っていると考えている。プロジェクトの方向性が変わってその判断が成り立たなくなったら、見直す。

決定 3: 再走査しない TrueType サブセッタ

CJK ベンチマーク (gpdf 133 µs 対 gofpdf 254 µs) の差はここから大半が出てくる。

TrueType サブセッタの役割をざっくり: PDF に日本語フォントを埋め込むとき、20,000 グリフ全部を埋め込みたくはない — 100 KB のドキュメントに 15 MB のフォントデータが乗ってしまう。ドキュメントで実際に使われるグリフだけ、PDF リーダーがデコードできる有効な subset TTF としてパッケージしたい。

そのためには:

  1. 完全な TTF テーブルをパース: cmap (文字→グリフ対応)、glyf (アウトライン)、loca (glyf へのオフセット)、hmtx (水平メトリクス) など。
  2. ドキュメント内の各文字について、cmap 経由でグリフ ID を引く。
  3. 合成グリフが参照する副次グリフも推移的に収集。
  4. 使うグリフのみ番号を振り直した新しい TTF を吐く。

ホットパスはステップ 2 — cmap ルックアップ。gofpdf の実装はグリフルックアップのたびに cmap テーブルを 先頭から歩く。Latin だけのページなら問題ない。cmap は小さくキャッシュも行儀がいい。CJK のページで 150 個のユニークグリフがあれば、テーブルを 150 回全走査することになる。

cmap format 12 (現代の CJK フォントで使われる) は (start, end, startGlyphID) の三つ組をソートして並べた配列。1 回の走査は範囲数に対して O(n) で、NotoSansJP なら 200〜500 範囲。150 ルックアップ × 範囲ごとの比較 × 400 範囲 = 必要量よりはるかに多い仕事。

gpdf はフォントの初回ロード時点で cmap 全体を map[rune]uint16 に展開する。その後のルックアップは全部 O(1)。NotoSansJP なら初回コストが約 150 µs、以降は 1 文字 10 ns。

// pdf/font/ttf.go より単純化
type Font struct {
    runeToGID map[rune]uint16  // ロード時に一度だけ解決
    glyphs    []glyph          // GID でインデックス
    metrics   []glyphMetric
}

func (f *Font) GlyphFor(r rune) uint16 {
    return f.runeToGID[r]  // O(1)、キャッシュフレンドリ、テーブル走査なし
}

rune でインデックスされたマップ 1 つ、cmap テーブルの線形スキャン 1 回で構築。同じフォントを複数ページ (=普通はすべてのページ) で使うドキュメントでは、グリフルックアップが「ページ数 × グリフ数のほぼ二次」から「総グリフ数 + 定数」に変わる。

「format 12」が要点

古い Go PDF ライブラリの多くは、Latin しか誰も気にしなかった頃に書かれた。実装されている cmap は format 4 — Basic Multilingual Plane (U+0000〜U+FFFF) のセグメント範囲。BMP 外の日本語 (一部の異体字 Kanji) には format 12 が必要。go-pdf/fpdfAddUTF8Font は NotoSansJP-Regular.ttf で panic する。format 12 のパーサが最後まで書かれていないからだ。

これは揶揄ではない。遺物だ。gofpdf は 2015 年頃の Latin 中心の Web アプリに必要なものとして優秀だった。フォークはそのスコープを引き継いだ。時代が変わり、CJK は「誰かの問題」から「日本語と中国語 Go エコシステムの多数派の問題」になった。gpdf は cmap 仕様を全実装した。しなければ「品目」のところに豆腐が並ぶ請求書が出力される — 公開 1 週目に実際に届いたバグ報告だ。

ドキュメント数ではなくフォント数でスケールするキャッシュ

フォントキャッシュは Document ごとで、グローバルではない。同じフォントで 10,000 PDF を生成すると、150 µs の解決コストを 10,000 回払う — ただし Font インスタンスをドキュメント間で共有すれば別で、gpdf.WithSharedFont(preloadedFont) で API が用意されている。

高スループットのバッチ生成 (SaaS の gpdf-api はこれ) では、この共有フォントパターンが P95 レイテンシを予測可能にする。docs で紹介している。OSS ユーザーの大半は必要ない。

組み合わさった効果

3 つの決定を 100 ページベンチマーク (gpdf 683 µs, gofpdf 11.7 ms) に当てはめると:

時間の出処gofpdf (ページあたり概算)gpdf (ページあたり概算)
operation スライス構築約 60 µs0 (直接ストリーム)
operation シリアライズ約 35 µs0 (既に書かれた)
グリフルックアップ (40 字)約 6 µs約 0.4 µs
割り当て / GC 圧約 20 µs約 2 µs
合計約 120 µs約 7 µs

数字はプロファイルからの推定で、内容によって実際の内訳は変わる。ただし形はこの通り。3 つのうちどれ 1 つも単独では 10 倍の勝ちにならない。積み重なって初めて 10 倍になる。

系として: 既存ライブラリに 1 つだけコピーすれば 2〜3 倍の改善は得られる。10 倍が欲しいなら 3 つ必要で、最初の 1 つ (単一パス) を AST ベースのライブラリに後付けするには書き直すしかない。

捨てたもの (正直の節)

ここまで言い方を工夫してきたが、全部列挙する:

AST ベースの後処理。 プラグインアーキテクチャなし。「ノードツリーを歩いて変換を適用」なし。ドキュメント全体のテキストスタイルをレンダリング前に一括編集したければ、Builder を呼ぶ にやる。

イントロスペクション。 doc.Components() で入れたもの全部を返すようなメソッドはない。意味のあるメソッドが走る時点で、ドキュメントはもう演算子のストリーム。大半のユーザーには関係ない。ドキュメント操作ツールを書く少数派には関係する。

reflection ベースのシリアライゼーション。 任意の構造体を PDF に変換する json.Unmarshal 風 API はない。JSON Schema 入口 (template.FromJSON) はサポートする形を明示している。意図的だ。汎用の Go 構造体を食わせて PDF を返す API が欲しければ unidoc の領域。

interface の拡張性。 Component を実装してカスタム要素を登録することはできない。Builder 呼び出しをラップするヘルパー関数は書ける。実用上はそれで 95% カバーできるが、モデルは違う。

全部意図した結果だ。1 つでも採用すれば速度は死ぬ。「速くて意見の強いモノ」が嬉しいバケットのユーザーを優先し、「柔軟でプラグイン豊富」が必要なバケットのユーザーは Maroto v2 か unidoc の方が合う。

ベンチマークの再現

できる。コードを公開している目的はそこ。

git clone https://github.com/gpdf-dev/gpdf
cd gpdf/_benchmark
go test -bench=. -benchmem -benchtime=5s

そのディレクトリの README に 4 つのワークロードと計測内容が書いてある。同じ CPU アーキテクチャ、同じ Go バージョンで 20% 以上ズレたら issue を立ててほしい — ドリフトは実在する。

2 つ補足:

  • ベンチは -benchmem 有効で走らせている。無効にすれば全体で約 5% 改善するが、実コードの走らせ方ではないので公開数字には入れない。
  • CGO 無効。FreeType バックエンドを CGO で組んだ方がフォント操作が速いのではという質問を受けて実験した。FFI 境界のマーシャリングコストが得より大きかった。PDF ジェネレータのアクセスパターンに対しては純 Go のサブセッタが勝つ。

FAQ

なぜアーカイブ済みの gofpdf と比較する? いまだに GitHub で「go pdf」検索のトップ結果で、gpdf に着地するチームの大半はそこから移行してくる。ベンチマークはこの層に「移行する価値があるか」を答える必要がある。答え: ある。移行ガイド も書いた。

PDF 生成において 10 倍速は実質意味があるのか? ワークロード次第。1 リクエスト 1 ドキュメントならほぼ関係ない — どちらも「リクエストパス上で生成」の閾値は越える。バッチ処理 (夜間明細、大量請求書、DB クエリからのレポート生成) では差がそのまま台数減になる。バッチパイプラインを最初に移行したチームから「ワーカー数が 10 分の 1」と聞いた。彼らの計算は監査していないが、ベンチの形と整合している。

CJK の数字の落とし穴は? フォントファイルは自分で同梱する必要がある。gpdf がサブセット化するが、3 MB の NotoSansJP TTF は Go バイナリに埋め込むか起動時に os.ReadFile するかのどちらか。distroless イメージでは効く。SaaS の gpdf-api はイメージに代表的フォントを同梱して解決している。OSS ユーザーは自分で扱う。

機能が増えたら遅くなるのか? 一番気にしている質問。答え: リリースごとに前バージョンとベンチマークを取り、4 ワークロードのいずれかで 5% 以上の悪化が出たらリリースを止める。ベンチがライブラリと同じリポジトリにある理由はまさにこれだ。

名前の由来は? gpdf = Go + PDF。捻りはない。狙ってシンプルにしてある。

gpdf を使ってみる

gpdf は Go の PDF 生成ライブラリ。MIT、ゼロ依存、CJK ネイティブ対応。

go get github.com/gpdf-dev/gpdf

⭐ Star on GitHub · ドキュメント

次に読む