Por que gpdf é 10–30 vezes mais rápido que outras bibliotecas Go PDF
gpdf gera uma página em 13 µs e um relatório de 100 páginas em 683 µs. Não é truque de tuning — são três decisões arquiteturais que se somam. Percorremos o código.
TL;DR
gpdf gera uma página em 13 µs, uma tabela de fatura 4×10 em 108 µs e um relatório paginado de 100 páginas em 683 µs. A próxima biblioteca Go PDF mais rápida em manutenção — jung-kurt/gofpdf — faz as mesmas 100 páginas em 11.7 ms, cerca de 17× mais lento. Não é diferença de tuning. São três decisões de design que empilham:
- Layout em passagem única. Nenhuma AST intermediária entre a API Builder e o fluxo de conteúdo PDF.
- Tipos concretos no caminho quente. Sem reflexão, sem
interface{}, sem dispatch virtual dentro do loop de layout. - Um subsetter TrueType que resolve o cmap uma vez. Não uma vez por glifo. Não uma vez por página. Uma vez.
Qualquer um dos três dá 2–3×. Empilhados, dá uma ordem de grandeza.
Este artigo percorre o caminho de código que produz esses números. O fonte do benchmark está público — _benchmark/benchmark_test.go — clone, rode na sua máquina, abra um issue se os números discordarem.
Aviso de viés adiantado: somos o time do gpdf. A versão honesta de "somos mais rápidos" é "tomamos um conjunto diferente de trade-offs", e a pergunta interessante é o que abrimos mão para chegar aqui. Essa é a segunda metade do artigo.
O que significa "rápido" aqui
Antes da arquitetura, o placar que vamos explicar (Apple M1, Go 1.25, sem CGO, -benchmem ativo):
| Carga de trabalho | gpdf | gofpdf | go-pdf/fpdf | signintech/gopdf | Maroto v2 |
|---|---|---|---|---|---|
| Página única Hello World | 13 µs | 132 µs | 135 µs | 423 µs | 237 µs |
| Tabela de fatura 4×10 | 108 µs | 241 µs | 243 µs | 835 µs | 8,600 µs |
| Relatório paginado 100 páginas | 683 µs | 11,700 µs | 11,900 µs | 8,600 µs | 19,800 µs |
| Fatura CJK complexa | 133 µs | 254 µs | n/a | 997 µs | 10,400 µs |
Duas formas visíveis antes de explicar. A distância aumenta com o número de páginas — 10× num hello world, 17× em 100 páginas. E a distância aumenta com a complexidade — 108 µs para uma tabela contra 8.6 ms para a mesma tabela pelo backend gofpdf do Maroto.
As duas formas vêm da mesma raiz: o custo por elemento no gpdf é quase plano, porque o loop de layout não aloca no caminho comum. O porquê vem a seguir.
Aviso rápido que ninguém quer ler mas escrevemos mesmo assim: velocidade absoluta importa menos do que as pessoas pensam para a maioria das cargas de PDF. Se seu maior documento é um recibo de uma página, qualquer biblioteca mantida dessa tabela é rápida o bastante para gerar no caminho da requisição. O limiar que importa é "consigo gerar 100 desses síncronos em lote sem empurrar pra fila", e é aí que a distância começa a contar.
Decisão 1: Sem AST intermediária
A maioria das bibliotecas Builder de PDF funciona assim:
API Builder → árvore de documento (AST) → passe de layout → serializador → bytes
O passo da árvore de documento é o problema. Cada chamada .Text() aloca um nó. Cada .Row() aloca um container. O passe de layout anda a árvore para calcular posições. Então o serializador anda de novo para emitir bytes. Três passes, três conjuntos de alocações, três viagens pelos mesmos dados pelo cache de CPU.
gpdf não tem o passo 2. O Builder escreve direto em um contexto de layout que escreve direto no fluxo de conteúdo. Uma passagem.
Eis o caminho concreto para um elemento de texto, editado por espaço (a versão real fica em 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)
}
Nenhum nó vai pra árvore. Nenhuma posição é adiada. O writer é um *pdf.Writer que guarda um io.Writer (tipicamente um bytes.Buffer), e BeginText / MoveTo / ShowString escrevem os operadores PDF (BT, Td, Tj, ET) imediatamente pro buffer.
Compare com como o gofpdf faz a mesma operação lógica. O gofpdf mantém um objeto page com um slice de operações. Cada chamada SetXY + Cell anexa a esse slice. Output (ou OutputFileAndClose) anda o slice no fim e emite os bytes. Duas alocações por célula — uma pela struct de operação, outra pela cópia de string — mais um passe extra sobre os dados.
Para um relatório de 100 páginas com ~40 linhas por página, são 4,000 alocações extras que o gpdf não faz.
Onde dói a passagem única
A pergunta óbvia: como fazer qualquer coisa que precisa conhecer o layout final da página antes de começar a emitir bytes? Cabeçalhos com números de página. Tabelas que atravessam páginas. Rodapés ancorados na última linha do corpo.
Duas respostas. Uma, bufferizamos a página atual, não o documento. Uma página é uma unidade limitada — dezenas de KB, não megabytes. Quando o próximo AddPage() roda, o fluxo de conteúdo da página atual é finalizado (Length, Filter, offsets), sua entrada xref é escrita, e o buffer de página é resetado. O pico de memória fica em O(uma página).
Dois, para elementos genuinamente globais ("Page 3 of 27"), adiamos aquele trecho específico para um passe de fix-up. O resto do conteúdo já está no fluxo. O fix-up percorre uma lista curta de marcadores deferred-reference e aplica patch. Esse é o único lugar na base de código onde pagamos algo parecido com custo de AST, e só pagamos para o conteúdo que realmente precisa.
O trade: você não pode fazer pós-processamento arbitrário numa árvore de nós, porque não há árvore de nós. Você não pode escrever um plugin que reordene "todos os nós Text com bold: true". Se precisar desse formato de API, Maroto v2 faz; gpdf não.
Achamos que é o trade certo para os casos de uso que gpdf mira. A maioria dos PDFs é produzida da esquerda pra direita, de cima pra baixo, em layout conhecido na hora da construção. O custo de manter uma AST para a minoria de casos que precisa foi pago em toda página pela maioria. Invertemos essa proporção.
Decisão 2: Sem reflexão, sem interface{} no caminho quente
Escrever sobre isso é menos interessante do que perfilar. Mas é de onde vem a outra metade da velocidade.
Veja a assinatura de CellFormat do gofpdf:
func (f *Fpdf) CellFormat(w, h float64, txtStr, borderStr string,
ln int, alignStr string, fill bool, link int, linkStr string) { ... }
Tudo bem. Agora veja a árvore de componentes do Maroto. Um Row tem []Component. Um Component é uma interface. Toda operação de layout é um dispatch virtual: component.Render(ctx). Para um único Col com um Text e um Spacer, são três dispatches. Num relatório de 100 páginas com ~30 linhas por página e ~3 componentes por linha, são ~9,000 dispatches.
Individualmente, um dispatch de interface em Go é ~2–3 ns. Não é crime. Mas o dispatch também força o compilador a manter o valor boxed na heap — você não consegue stack-alocar através de uma interface sem um passe de devirtualization que o compilador Go nem sempre faz. Então o custo não é só o dispatch; é a alocação que o alimenta.
O engine de layout do gpdf usa structs concretas:
type RowBuilder struct {
doc *Document
parent *pageState
spans [12]int
cols [12]ColBuilder // valor, não ponteiro, não interface
n uint8
}
type ColBuilder struct {
row *RowBuilder
span int
cursor document.Point
writer *pdf.Writer
}
cols é um array de valor, dimensionado pelo máximo de colunas (12, do sistema de grid). Sem alocação em heap. Sem dispatch de interface quando a row itera suas colunas. O Builder guarda um ponteiro pro writer, não o contrário — o writer não sabe da existência da árvore do Builder.
O padrão de callback (r.Col(4, func(c *ColBuilder) { ... })) não é acidente. Todas as outras formas que prototipamos — uma API que retorna structs encadeáveis, uma árvore de interfaces Component boxed — foram mais lentas. A closure tem zero alocações porque o ColBuilder é um valor que o chamador mantém por ponteiro via parâmetro; a própria closure é escape-analisada pra pilha no caso comum.
Como sabemos que funcionou
go test -run=XXX -bench=BenchmarkSinglePage -memprofile=mem.out no gpdf dá um número do qual a gente tem orgulho:
BenchmarkSinglePage-8 91270 13120 ns/op 8321 B/op 52 allocs/op
Cinquenta e duas alocações pra uma página PDF inteira. Quase todas são o buffer inicial da página, a busca de métricas de fonte (uma por fonte, não uma por glifo) e o crescimento final do bytes.Buffer. O loop de layout aloca zero — olhe o profile.
gofpdf na mesma página:
BenchmarkGofpdfSinglePage-8 7500 132400 ns/op 71200 B/op 430 allocs/op
430 alocações. A maioria é o slice de operações e as cópias de string que o alimentam. Passe essa diferença de ~8× em alocações pelo GC, e a diferença de runtime de ~10× segue mecanicamente.
O que cedemos
Zero ergonomia no caminho quente significa menos pontos de extensão. Se você quer escrever um tipo de elemento customizado que se pluga no layout do gpdf — o equivalente a implementar Component no Maroto — não dá. Não existe interface a satisfazer. O que oferecemos no lugar é template.WithWriterSetup(), que dá um hook no writer PDF para coisas como anotações customizadas, metadados PDF/A ou criptografia. Para extensão de layout, você escreve como helper que chama os mesmos métodos Builder que um usuário chamaria.
Menos pontos de extensão é um custo real. Decidimos que vale a pena. Se a direção do projeto mudar num ponto onde não vale, revisitamos.
Decisão 3: Subsetting TrueType sem re-percorrimentos
É aqui que o benchmark CJK (133 µs vs 254 µs do gofpdf) pega a maior parte da distância.
Um resumo rápido do que o subsetting TrueType faz. Quando você embute uma fonte japonesa num PDF, você não quer embutir todos os 20,000+ glifos — são 15 MB de dados de fonte num documento de 100 KB. Você quer embutir só os glifos que seu documento realmente usa, empacotados como um TTF subset válido que um leitor de PDF consiga decodificar.
Para isso:
- Parse das tabelas TTF completas:
cmap(mapeamento caractere-pra-glifo),glyf(contornos),loca(offsets no glyf),hmtx(métricas horizontais), etc. - Para cada caractere que o documento usa, buscar seu ID de glifo via o cmap.
- Coletar transitivamente os glifos que glifos compostos referenciam.
- Emitir um TTF novo só com esses glifos, renumerados.
Passo 2 — a busca no cmap — é o caminho quente. A implementação do gofpdf percorre a tabela cmap do topo a cada busca de glifo. Pra uma página só Latin, tudo bem; o cmap é pequeno e o cache se comporta. Pra uma página CJK com 150 glifos únicos, são 150 percorrimentos completos da tabela.
O cmap format 12 (usado por quase toda fonte CJK moderna) é um array ordenado de triplas (start, end, startGlyphID). Um percorrimento é O(n) no número de ranges, ~200–500 para NotoSansJP. 150 buscas de glifo × 400 ranges × comparação por range = muito mais trabalho do que o necessário.
O gpdf resolve o cmap inteiro em um map[rune]uint16 no primeiro load da fonte. Depois disso, cada busca é O(1). Pro NotoSansJP, o custo único é ~150 µs; depois, 10 ns por caractere.
// Simplificado de pdf/font/ttf.go
type Font struct {
runeToGID map[rune]uint16 // resolvido uma vez no load
glyphs []glyph // indexado por GID
metrics []glyphMetric
}
func (f *Font) GlyphFor(r rune) uint16 {
return f.runeToGID[r] // O(1), cache-friendly, sem percorrer tabela
}
Um mapa, indexado por rune, populado por uma varredura linear única da tabela cmap. Pra um documento que usa a mesma fonte em várias páginas (todas), isso move a busca de glifos de "quase quadrática em páginas × glifos" pra "linear em total de glifos mais uma constante fixa".
Por que "format 12" é o detalhe que importa
A maioria das bibliotecas Go PDF mais antigas foi escrita quando texto Latin era o que todo mundo se importava, e implementaram cmap format 4 — um range segmentado pro Basic Multilingual Plane (U+0000–U+FFFF). Texto japonês fora do BMP (menos comum, mas algumas variantes Kanji) precisa de format 12. O AddUTF8Font do go-pdf/fpdf dá panic no NotoSansJP-Regular.ttf porque o parser de format 12 nunca foi terminado.
Não é uma crítica. É um artefato: gofpdf foi uma ótima biblioteca pro que web apps Latin-heavy precisavam em 2015, e o fork herdou o escopo. O mundo mudou; CJK foi de "problema de outro" pra "problema da maioria dos ecossistemas Go do Japão e da China". gpdf implementou a spec cmap completa porque a alternativa era uma fatura que mostra caixas de tofu pra 品目 — um bug report real da primeira semana depois do release público.
Cache que escala com número de fontes, não com tamanho do documento
O cache de fontes é por Document, não global. Se você gera 10,000 PDFs com a mesma fonte, paga o custo de resolução de 150 µs 10,000 vezes — a menos que compartilhe uma instância Font entre documentos, o que a API permite via gpdf.WithSharedFont(preloadedFont).
Pra geração em lote de alto volume (o SaaS gpdf-api roda assim), o padrão de fonte compartilhada é o que deixa a latência P95 previsível. Publicamos nos docs; a maioria dos usuários OSS não precisa.
O efeito combinado
Colocamos as três decisões lado a lado no benchmark de 100 páginas (683 µs pro gpdf, 11.7 ms pro gofpdf):
| Origem do tempo | gofpdf (por página, aprox) | gpdf (por página, aprox) |
|---|---|---|
| Construção do slice ops | ~60 µs | 0 (stream direto) |
| Serialização das ops | ~35 µs | 0 (já escrito) |
| Buscas de glifo (40 chars) | ~6 µs | ~0.4 µs |
| Alocação / pressão de GC | ~20 µs | ~2 µs |
| Total | ~120 µs | ~7 µs |
Os números são estimativas de profiling; a decomposição real depende do conteúdo. Mas o formato está certo. Nenhum dos três designs ganha 10× sozinho. Eles somam.
Corolário: se você copia só um design numa biblioteca existente, você consegue 2–3×. Se quer o 10×, precisa dos três, e você não consegue retrofit o primeiro (passagem única) numa biblioteca baseada em AST sem reescrevê-la.
O que cedemos (a seção honesta)
Ficamos dançando em volta. A lista completa:
Pós-processamento baseado em AST. Sem arquitetura de plugins. Sem "percorre a árvore de nós e aplica esta transformação". Se quer editar estilos de texto globalmente antes de renderizar, você faz antes de chamar o Builder, não depois.
Introspecção. Não existe doc.Components() que devolva tudo o que você colocou. O documento é um fluxo de operadores na hora em que qualquer método significativo consegue rodar. Pra maioria dos usuários isso nunca aparece; pra minoria que escreve ferramentas de manipulação de documentos, aparece.
Serialização por reflexão. Não temos uma API estilo json.Unmarshal que converte structs arbitrárias em PDF. O ponto de entrada JSON Schema (template.FromJSON) é explícito sobre os formatos que suporta, de propósito. Se você quer apontar uma biblioteca pra uma struct Go genérica e receber um PDF, isso é território do unidoc.
A extensibilidade de uma interface. Você não pode implementar Component e registrar um elemento customizado. Pode escrever uma função helper que envolve as chamadas Builder, e na prática isso cobre 95% do que o pessoal pede, mas é um modelo diferente.
São deliberadas. Cada uma individualmente mataria a velocidade. Escolhemos o grupo de usuários cujo trabalho se beneficia de "rápido e opinativo" sobre o grupo que precisa de "flexível e rico em plugins". Se você tá no segundo grupo, Maroto v2 ou unidoc provavelmente encaixam melhor.
Dá pra rodar o benchmark?
Dá. Esse é todo o propósito de publicar o código.
git clone https://github.com/gpdf-dev/gpdf
cd gpdf/_benchmark
go test -bench=. -benchmem -benchtime=5s
O README naquele diretório documenta as quatro cargas e o que elas medem. Se seus números diferirem materialmente (>20%) na mesma arquitetura de CPU e versão do Go, abra um issue — drift existe e queremos saber.
Duas ressalvas:
- O benchmark roda com
-benchmem. Se desativar, os números melhoram ~5% no geral, o que a gente não conta em afirmações públicas porque não é como ninguém roda código real. - CGO tá off. Alguns leitores perguntaram se um backend FreeType em CGO seria mais rápido pra operações de fonte; testamos, e o custo de marshaling na fronteira FFI dominou qualquer ganho. O subsetter em Go puro ganha pros padrões de acesso que um gerador PDF tem.
FAQ
Por que comparar com gofpdf se tá arquivado? Porque ainda é o primeiro resultado no GitHub pra "go pdf", e a maioria dos times que chega no gpdf tá migrando de lá. O benchmark precisa responder "a migração vale o esforço?" pra esse público. Versão curta: vale, e escrevemos um guia de migração.
10× mais rápido é realmente significativo pra geração de PDF? Depende da carga. Pra um documento por requisição, nem tanto — as duas bibliotecas passam o limiar de "gerar na request". Pra operações em lote (extratos noturnos, faturas em massa, geração de relatórios a partir de query em DB), a distância se traduz direto em menos máquinas. Ouvimos "10× menos workers" do primeiro time que migrou o pipeline de lotes; não auditamos as contas deles mas bate com o benchmark.
Qual é a pegadinha no número CJK?
Você ainda precisa enviar o arquivo de fonte. gpdf faz o subset, mas um TTF NotoSansJP de 3 MB é 3 MB que você ou embute no binário Go ou faz os.ReadFile na inicialização. Pra imagens distroless isso importa. O SaaS gpdf-api resolve enviando as fontes comuns na imagem; usuários OSS lidam por conta.
gpdf vai ficar mais lento conforme features forem adicionadas? É a pergunta que mais nos importa. Resposta: fazemos benchmark de cada release contra a anterior, e regressão maior que 5% em qualquer das quatro cargas bloqueia a release. Os benchmarks vivem no mesmo repo que a biblioteca exatamente por isso.
De onde vem o nome? gpdf = Go + PDF. Não é esperto. É intencional.
Testar o gpdf
gpdf é uma biblioteca Go pra gerar PDFs. MIT, zero dependências, CJK nativo.
go get github.com/gpdf-dev/gpdf
⭐ Star on GitHub · Ler os docs
Leituras seguintes
- Comparativo de bibliotecas Go PDF 2026 — comparação completa com licenças e dependências.
- gofpdf foi arquivado. Como migrar pro gpdf. — cinco pares de API antes/depois, todos executáveis.
- O código do benchmark:
_benchmark/benchmark_test.go.