为什么 gpdf 比其他 Go PDF 库快 10–30 倍
单页 13 µs,100 页报告 683 µs。不是调参,而是三个架构决策的叠加。本文走一遍代码路径。
TL;DR
gpdf 生成单页 13 µs,4×10 发票表格 108 µs,100 页分页报告 683 µs。次快的 jung-kurt/gofpdf 做同样的 100 页需要 11.7 ms,大约慢 17 倍。这不是调参差异,而是三个设计决策相互叠加的结果:
- 单遍布局。 Builder API 和 PDF 内容流之间没有中间 AST。
- 热点路径使用具体类型。 布局循环里没有反射、没有
interface{}、没有虚拟派发。 - TrueType 子集器只解析 cmap 一次。 不是每个字形一次,也不是每页一次。就一次。
三个里任何一个单独都能带来 2–3 倍。叠起来就是一个数量级。
本文直接追那些产生这些数字的代码路径。基准测试源码公开在 _benchmark/benchmark_test.go — 克隆下来在自己的机器上跑,数字对不上就提 issue。
先声明偏向: 我们是 gpdf 团队。"我们更快"的诚实版本是"我们做了不同的权衡",真正有意思的问题是 为了这份速度放弃了什么。文章后半讲这个。
"快"在这里指什么
在讲架构之前,先把要解释的积分板列出来 (Apple M1, Go 1.25, 关闭 CGO, -benchmem 开启):
| 工作负载 | gpdf | gofpdf | go-pdf/fpdf | signintech/gopdf | Maroto v2 |
|---|---|---|---|---|---|
| 单页 Hello World | 13 µs | 132 µs | 135 µs | 423 µs | 237 µs |
| 4×10 发票表格 | 108 µs | 241 µs | 243 µs | 835 µs | 8,600 µs |
| 100 页分页报告 | 683 µs | 11,700 µs | 11,900 µs | 8,600 µs | 19,800 µs |
| 复杂 CJK 发票 | 133 µs | 254 µs | n/a | 997 µs | 10,400 µs |
在解释之前能看到两个形状。页数越多 差距越大 (Hello World 10 倍、100 页 17 倍)。复杂度越高 差距越大 (表格单独 108 µs,Maroto 经 gofpdf 后端 8.6 ms)。
两个形状的根源相同: gpdf 的布局循环在公共路径上不分配内存,所以每个元素的成本几乎是平的。原因下面讲。
免责声明没人想读但还是要写: 对大多数 PDF 工作负载,绝对速度没想象中那么重要。如果最大的文档只是一张一页收据,这张表里的每个维护中的库都能在请求路径上生成。起作用的阈值是"能不能在一个批次里同步生成 100 份而不排队"。
决策 1: 不构造中间 AST
大多数 PDF Builder 库是这样工作的:
builder API → 文档树 (AST) → 布局遍历 → 序列化器 → 字节
文档树那一步是问题。每次 .Text() 都分配一个节点。每次 .Row() 都分配一个容器。布局遍历走一次树算位置,序列化器再走一次树吐字节。三次遍、三组分配、三趟 CPU 缓存。
gpdf 没有第 2 步。Builder 直接写入布局上下文,布局上下文直接写入内容流。一遍。
文本元素实际的代码路径 (从 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 算子写进 buffer。
对比 gofpdf 怎么做同样的逻辑操作。gofpdf 维护一个 page 对象,里面有一个操作切片。每次 SetXY + Cell 调用都追加到那个切片。最后 Output (或 OutputFileAndClose) 走一遍切片把字节吐出来。每个 cell 两次分配 — 一次操作结构体、一次字符串拷贝 — 加上对数据的额外一遍扫描。
100 页报告每页约 40 行,就是 gpdf 不会做的 4,000 次额外分配。
单遍路线痛在哪里
明显的疑问: 那些必须在开始写字节之前就知道最终页面布局的功能怎么办? 带页码的页眉。跨页表格。锚在最后一行正文下的页脚。
两个回答。第一,缓冲的是 页,不是整个文档。一页是有界单元,几十 KB,不是几 MB。下一次 AddPage() 被调用时,当前页的内容流会被敲定 (Length、Filter、偏移),它的 xref 入口被写出,页缓冲被重置。内存高水位保持在 O(一页)。
第二,对真正的全局元素 ("第 3/27 页"),把 那部分范围 延迟到 fix-up 扫描。其余内容已经在流里。fix-up 扫一个短的 deferred-reference 标记列表并打补丁。这是代码库里唯一支付接近 AST 代价的地方,而且 只对真正需要的部分支付。
代价是: 你没法对节点树做任意后处理,因为根本没有节点树。你没法写一个"把所有 bold: true 的 Text 节点重排"的插件。需要这种 API 形状就用 Maroto v2。
我们认为这个权衡对 gpdf 面向的使用场景是对的。大多数 PDF 是从左到右、从上到下按构造时就知道的布局生成的。为少数场景保留 AST 的成本被多数场景在每页都付。比例被我们反过来了。
决策 2: 热点路径没有反射和 interface
写起来不比上面有趣,但用 profile 看,剩下一半速度差距在这里。
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)。一个 Col 里放一个 Text 和一个 Spacer 就是三次派发。100 页 × 每页 30 行 × 每行 3 组件 = 9,000 次派发。
单次 Go interface 派发约 2–3 ns,单独不算罪。但 interface 也强制编译器把装箱的值放到堆上 — 没有 Go 编译器不总能做的去虚化,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 派发。Builder 持 writer 的指针,而 writer 不知道 Builder 树的存在。
回调模式 (r.Col(4, func(c *ColBuilder) { ... })) 不是偶然。我们原型过的其他形式 — 返回可链式调用的 struct、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
整个 PDF 页 52 次分配。几乎全部是初始页缓冲、字体度量查找 (每字体一次,不是每字形一次)、最后的 bytes.Buffer 扩容。布局循环零分配 — 看 profile 就知道。
gofpdf 同一页:
BenchmarkGofpdfSinglePage-8 7500 132400 ns/op 71200 B/op 430 allocs/op
430 次分配。大部分是操作切片和填它的字符串拷贝。把这 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 阅读器能解码的有效子集 TTF。
流程:
- 解析完整 TTF 表:
cmap(字符到字形映射)、glyf(轮廓)、loca(到 glyf 的偏移)、hmtx(水平度量) 等。 - 对文档每个字符,通过 cmap 查字形 ID。
- 递归收集复合字形引用的字形。
- 输出只含那些字形、重新编号的新 TTF。
步骤 2 — cmap 查找 — 是热点路径。gofpdf 实现 每次字形查找都从头走一遍 cmap 表。只含 Latin 的页面没问题; cmap 小、缓存友好。一个 CJK 页面有 150 个唯一字形,就是对表做 150 次完整走查。
cmap format 12 (大多数现代 CJK 字体使用) 是 (start, end, startGlyphID) 三元组的有序数组。一次走查对范围数是 O(n),NotoSansJP 大约 200–500 个范围。150 次查找 × 每范围比较 × 400 范围 = 远超必要的工作量。
gpdf 在字体首次加载时把整张 cmap 展开成 map[rune]uint16。之后每次查找都是 O(1)。NotoSansJP 的一次性成本约 150 µs,之后每字符 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 索引的 map,对 cmap 表做一次线性扫描构建。多个页面使用同一字体的文档 (通常是所有页) 中,字形查找从"约为页数 × 字形数的二次"变成"总字形数加常量"。
为什么 "format 12" 是关键细节
很多老的 Go PDF 库写于只关注 Latin 文本的年代,实现的是 cmap format 4 — Basic Multilingual Plane (U+0000–U+FFFF) 的分段范围。BMP 之外的日文 (不常见但有一些异体 Kanji) 需要 format 12。go-pdf/fpdf 的 AddUTF8Font 在 NotoSansJP-Regular.ttf 上 panic,因为 format 12 的解析器从来没写完。
这不是吐槽。这是历史遗物: gofpdf 在 2015 年对 Latin 为主的 Web 应用来说是一个优秀的库,fork 继承了它的范围。世界变了,CJK 从"别人的问题"变成"日文和中文 Go 生态的多数问题"。gpdf 实现了完整 cmap 规范,因为另一种选择是给"品目"渲染出豆腐方块的发票 — 这是公开发布第一周就收到的真实 bug 报告。
按字体数扩展的缓存
字体缓存按 Document 而不是全局。用同一字体生成 10,000 份 PDF,就要付 10,000 次 150 µs 的解析成本 — 除非跨文档共享 Font 实例,API 通过 gpdf.WithSharedFont(preloadedFont) 支持。
对高吞吐批量生成 (SaaS 的 gpdf-api 就是这样),这个共享字体模式让 P95 延迟可预测。文档里有说明。OSS 用户多数不需要。
三个决策叠加的效果
把三个决策拿到 100 页基准 (gpdf 683 µs, gofpdf 11.7 ms) 上:
| 时间去向 | gofpdf (每页概估) | gpdf (每页概估) |
|---|---|---|
| 操作切片构建 | 约 60 µs | 0 (直接流) |
| 操作序列化 | 约 35 µs | 0 (已写入) |
| 字形查找 (40 字符) | 约 6 µs | 约 0.4 µs |
| 分配 / GC 压力 | 约 20 µs | 约 2 µs |
| 合计 | 约 120 µs | 约 7 µs |
数字是从 profile 估的,实际分布看内容。但形状是对的。三个里任何一个单独都赢不了 10 倍。叠在一起才 10 倍。
推论: 把其中一个设计抄到已有库里能拿到 2–3 倍。想要 10 倍得三个都要,而且把第一个 (单遍) 改到基于 AST 的库里只能重写。
放弃了什么 (诚实那一节)
之前一直绕着说。列全:
基于 AST 的后处理。 没有插件架构。没有"走节点树应用变换"。要在渲染前全局编辑整个文档的文本样式,在调用 Builder 之前 做。
内省。 没有 doc.Components() 返回放进去的所有东西。任何有意义的方法能跑的时候,文档已经是操作符流了。多数用户用不上。写文档操作工具的少数用户用得上。
基于反射的序列化。 没有 json.Unmarshal 风格把任意 struct 变成 PDF 的 API。JSON Schema 入口 (template.FromJSON) 明确支持的形状,故意的。要把通用 Go struct 喂进去得到 PDF,那是 unidoc 的领地。
interface 的扩展性。 不能实现 Component 注册自定义元素。可以写一个包裹 Builder 调用的辅助函数。实用上能覆盖 95% 需求,但模型不同。
都是刻意的。任何一个采纳都会让速度死掉。我们选择优先服务"快而有主见"受益的那桶用户,"灵活和插件丰富"那桶用户 Maroto v2 或 unidoc 更合适。
能复现基准吗
能。公开代码的全部意义就是这个。
git clone https://github.com/gpdf-dev/gpdf
cd gpdf/_benchmark
go test -bench=. -benchmem -benchtime=5s
那个目录的 README 讲了四个工作负载和度量什么。在同架构、同 Go 版本下差距超过 20%,提 issue — drift 真实存在。
两个补充:
- 基准带
-benchmem。去掉能全面提升约 5%,但那不是真实代码的跑法,所以不写进公开数字。 - 关 CGO。有人问 CGO 挂 FreeType 做字体操作会不会更快,测过,FFI 边界的 marshal 代价盖过收益。对 PDF 生成器的访问模式,纯 Go 子集器赢。
FAQ
为什么要和已归档的 gofpdf 比? 因为它还是 GitHub 搜 "go pdf" 的第一名,落到 gpdf 的团队多数是从那迁来的。基准需要回答这波人"值不值得迁"。简版: 值得,还写了 迁移指南。
PDF 生成 10 倍快真有意义吗? 看工作负载。单请求单文档 — 其实没差,两边都过"请求路径内生成"的门槛。批处理 (夜间账单、大批量发票、从 DB 查询生成报表),差距直接对应机器数变少。第一个迁批处理流水线的团队反馈"worker 数变成十分之一",没审他们的算账,但和基准的形状一致。
CJK 那个数字的陷阱?
字体文件你得自己带。gpdf 帮你做子集,但 3 MB 的 NotoSansJP TTF 是 3 MB,要嘛编进 Go 二进制要嘛启动时 os.ReadFile。在 distroless 镜像里这会影响。SaaS gpdf-api 通过在镜像里带常用字体解决。OSS 用户自理。
加功能会不会变慢? 我们最在意这问题。答: 每个 release 都和上一版跑基准,四个负载任意一个退化超过 5% 就卡 release。基准和库在同一仓库,原因就在此。
名字哪来的? gpdf = Go + PDF。没有巧思。刻意简单。
试用 gpdf
gpdf 是 Go 的 PDF 生成库。MIT、零依赖、原生 CJK。
go get github.com/gpdf-dev/gpdf
下一步阅读
- 2026 年 Go PDF 库横评 — 含许可证和依赖的完整库比较。
- gofpdf 已归档,如何迁移到 gpdf — 五组 Before/After API 对照,全部可运行。
- 基准代码:
_benchmark/benchmark_test.go。