GoでPDF作成のライブラリといえば gopdf(signintech/gopdf) が広く使われているかと思います
そんなgopdfですがフォント設定のオーバーヘッドがイシューで報告されていました
私が携わっているプロジェクトでもそれが引き金となってしまい、バッチ処理がOOM Killされてしまうという障害が発生しました
オーバーヘッドの原因
gopdfにはgopdf.SubsetFontObjというフォントを表す構造体があります
// SubsetFontObj pdf subsetFont object
type SubsetFontObj struct {
ttfp core.TTFParser
Family string
CharacterToGlyphIndex *MapOfCharacterToGlyphIndex
CountOfFont int
indexObjCIDFont int
indexObjUnicodeMap int
ttfFontOption TtfOption
funcKernOverride FuncKernOverride
funcGetRoot func() *GoPdf
addCharsBuff []rune
}
SubsetFontObj はユーザーランドで生成・管理されることを想定されておらず、gopdf.GoPdf#AddTTFFont.* メソッドを通して内部で生成されます
また、フォントデータの実体は SubsetFontObj.ttfp もといgopdf/fontmaker/core.TTFParserという構造体で表されます
// TTFParser true type font parser
type TTFParser struct {
tables map[string]TableDirectoryEntry
//head
unitsPerEm uint
xMin int
yMin int
xMax int
yMax int
indexToLocFormat int
//Hhea
numberOfHMetrics uint
ascender int
descender int
//end Hhea
numGlyphs uint
widths []uint
chars map[int]uint
postScriptName string
//os2
os2Version uint
Embeddable bool
Bold bool
typoAscender int
typoDescender int
capHeight int
sxHeight int
//post
italicAngle int
underlinePosition int
underlineThickness int
isFixedPitch bool
sTypoLineGap int
usWinAscent uint
usWinDescent uint
//cmap
IsShortIndex bool
LocaTable []uint
SegCount uint
StartCount []uint
EndCount []uint
IdRangeOffset []uint
IdDelta []uint
GlyphIdArray []uint
symbol bool
//cmap format 12
groupingTables []CmapFormat12GroupingTable
//data of font
cachedFontData []byte
//kerning
useKerning bool //user config for use or not use kerning
kern *KernTable
}
ttfp は SubsetFontObj#AddTTFFont.* メソッドを通じてフォントをパースして生成されます
そのため、パースされたフォントデータはPDFごとに管理され、PDFを生成するたびにフォントデータをパースし直す必要がありました
FontContainer
gopdf.FontContainerは SubsetFontObj の再利用性を高めるための構造体です
SubsetFontObj のライフサイクルを gopdf.GoPdf から分離することによって、O(n)であったパース処理をO(1)に改善することができました
func main() {
+ fontContainer := &gopdf.FontContainer{}
+ err := fontContainer.AddTTFFont("LiberationSerif-Regular", "path/to/LiberationSerif-Regular.ttf")
+ if err != nil {
+ // handle error
+ }
pdf := &gopdf.GoPdf{}
pdf.Start(gopdf.Config{PageSize: *gopdf.PageSizeA4})
- err = pdf.AddTTFFont("LiberationSerif-Regular", "path/to/LiberationSerif-Regular.ttf")
+ err = pdf.AddTTFFontFromFontContainer("LiberationSerif-Regular", fontContainer)
if err != nil {
// handle error
}
}
FontContainer へのフォント追加は sync.Map によってgoroutine safeに実装されていますが、同名フォントの追加(上書き)を許容しています
そのため、フォント追加はinit関数、もしくは sync.Once で行うのが個人的にはおすすめです
package main
import (
"sync"
"github.com/signintech/gopdf"
)
type UseCase struct {
fontContainer *gopdf.FontContainer
once sync.Once
}
func (u *UseCase) Exec() error {
var err error
u.once.Do(func() {
err = u.fontContainer.AddTTFFont("LiberationSerif-Regular", "path/to/LiberationSerif-Regular.ttf")
})
if err != nil {
return err
}
pdf := &gopdf.GoPdf{}
pdf.Start(gopdf.Config{PageSize: *gopdf.PageSizeA4})
err := pdf.AddTTFFontFromFontContainer("LiberationSerif-Regular", u.fontContainer)
if err != nil {
return err
}
// PDFの生成処理
return nil
}
SubsetFontObj には一部PDFごとに異なる値を必要とするフィールドが存在するため、FontContainer ではこれらのフィールドをゼロ値のままで保持し、GoPdf#AddTTFFontFromFontContainer によって値を解決しています
ベンチマーク
Codespaces 4-core 上でベンチマークテストを実行すると以下のような結果が得られました
利用したフォントは LiberationSerif-Regular のTTF(388,352 バイト)です
go test -bench '^BenchmarkAddTTFFont.*$' -count 6 -benchmem | tee benchstat.txt
goos: linux
goarch: amd64
pkg: github.com/signintech/gopdf
cpu: AMD EPYC 7763 64-Core Processor
BenchmarkAddTTFFontFromFontContainer-4 490024 2172 ns/op 3740 B/op 45 allocs/op
BenchmarkAddTTFFontFromFontContainer-4 531178 2356 ns/op 3740 B/op 45 allocs/op
BenchmarkAddTTFFontFromFontContainer-4 531594 2117 ns/op 3740 B/op 45 allocs/op
BenchmarkAddTTFFontFromFontContainer-4 511210 2243 ns/op 3740 B/op 45 allocs/op
BenchmarkAddTTFFontFromFontContainer-4 564414 2253 ns/op 3740 B/op 45 allocs/op
BenchmarkAddTTFFontFromFontContainer-4 502152 2160 ns/op 3740 B/op 45 allocs/op
BenchmarkAddTTFFontByReader-4 1020 1124092 ns/op 2354553 B/op 6039 allocs/op
BenchmarkAddTTFFontByReader-4 1033 1140968 ns/op 2353839 B/op 6039 allocs/op
BenchmarkAddTTFFontByReader-4 1069 1276476 ns/op 2354374 B/op 6039 allocs/op
BenchmarkAddTTFFontByReader-4 1044 1179830 ns/op 2355059 B/op 6039 allocs/op
BenchmarkAddTTFFontByReader-4 1090 1020190 ns/op 2353677 B/op 6039 allocs/op
BenchmarkAddTTFFontByReader-4 981 1142982 ns/op 2354842 B/op 6039 allocs/op
BenchmarkAddTTFFontData-4 3322 371453 ns/op 359668 B/op 6011 allocs/op
BenchmarkAddTTFFontData-4 3295 366550 ns/op 359782 B/op 6011 allocs/op
BenchmarkAddTTFFontData-4 3276 359499 ns/op 359392 B/op 6011 allocs/op
BenchmarkAddTTFFontData-4 3174 358636 ns/op 359632 B/op 6011 allocs/op
BenchmarkAddTTFFontData-4 3430 364703 ns/op 359680 B/op 6011 allocs/op
BenchmarkAddTTFFontData-4 3440 357494 ns/op 359584 B/op 6011 allocs/op
PASS
ok github.com/signintech/gopdf 24.522s
benchstat benchstat.txt
goos: linux
goarch: amd64
pkg: github.com/signintech/gopdf
cpu: AMD EPYC 7763 64-Core Processor
│ benchstat.txt │
│ sec/op │
AddTTFFontFromFontContainer-4 2.208µ ± 7%
AddTTFFontByReader-4 1.142m ± 12%
AddTTFFontData-4 362.1µ ± 3%
geomean 97.01µ
│ benchstat.txt │
│ B/op │
AddTTFFontFromFontContainer-4 3.652Ki ± 0%
AddTTFFontByReader-4 2.245Mi ± 0%
AddTTFFontData-4 351.2Ki ± 0%
geomean 143.4Ki
│ benchstat.txt │
│ allocs/op │
AddTTFFontFromFontContainer-4 45.00 ± 0%
AddTTFFontByReader-4 6.039k ± 0%
AddTTFFontData-4 6.011k ± 0%
geomean 1.178k
AddTTFFontFromFontContainer についても45回ものアロケーションが発生しているため、完全に最適化できているわけではありませんが、既存メソッドと比較すると十分な改善と言えるかと思います
以下はbenchstatをもとにした AddTTFFontByReader, AddTTFFontData との比較結果です
メソッド | 処理時間 | メモリ使用量 | アロケーション |
---|---|---|---|
AddTTFFontByReader |
517.2x | 614.7x | 134.2x |
AddTTFFontData |
163.9x | 96.1x | 133.5x |
おわりに
gopdf.FontContainer の導入によって、フォントのパース処理をO(1)に改善し、メモリ使用量を最大614倍、処理時間を最大517倍向上させることが可能になりました
この機能はv0.33.0から利用可能です
go get -u github.com/signintech/gopdf@v0.33.0
私個人の話をすると実務で活用しているOSSへのコントリビュートは初めてだったため、ひとつコンフォートゾーンを抜け出すことができたように感じます
この機能がいつか誰かの助けになればエンジニア冥利に尽きます