Todas las publicaciones

Por qué gpdf es 10–30 veces más rápido que otras bibliotecas Go PDF

gpdf genera una página en 13 µs y un informe de 100 páginas en 683 µs. No es un truco de tuning: son tres decisiones arquitectónicas que se suman. Este artículo recorre el código.

por gpdf team

TL;DR

gpdf genera una página en 13 µs, una tabla de factura 4×10 en 108 µs y un informe paginado de 100 páginas en 683 µs. La siguiente biblioteca Go PDF más rápida en mantenimiento — jung-kurt/gofpdf — hace las mismas 100 páginas en 11.7 ms, unas 17 veces más lento. No es una diferencia de tuning. Son tres decisiones de diseño que se apilan:

  1. Layout de una sola pasada. Sin AST intermedio entre la API Builder y el flujo de contenido PDF.
  2. Tipos concretos en el camino caliente. Sin reflexión, sin interface{}, sin despacho virtual dentro del bucle de layout.
  3. Un subconjuntador TrueType que resuelve el cmap una vez. No una vez por glifo. No una vez por página. Una vez.

Cualquiera de los tres te da 2–3×. Apilados, te da un orden de magnitud.

Este artículo recorre el camino de código que produce esos números. El código de los benchmarks es público — _benchmark/benchmark_test.go — clónalo, vuelve a correrlo en tu hardware, abre un issue si los números no coinciden.

Aviso de sesgo por adelantado: somos el equipo de gpdf. La versión honesta de "somos más rápidos" es "tomamos un conjunto distinto de compromisos", y la pregunta interesante es qué sacrificamos para llegar aquí. Esa es la segunda mitad del artículo.

Qué significa "rápido" aquí

Antes de la arquitectura, el marcador que vamos a explicar (Apple M1, Go 1.25, sin CGO, -benchmem activo):

Carga de trabajogpdfgofpdfgo-pdf/fpdfsignintech/gopdfMaroto v2
Página única Hello World13 µs132 µs135 µs423 µs237 µs
Tabla de factura 4×10108 µs241 µs243 µs835 µs8,600 µs
Informe paginado de 100 págs683 µs11,700 µs11,900 µs8,600 µs19,800 µs
Factura CJK compleja133 µs254 µsn/a997 µs10,400 µs

Dos formas que se ven antes de explicarlas. El hueco se ensancha con el número de páginas — 10× en una hoja, 17× en 100 páginas. Y el hueco se ensancha con la complejidad — 108 µs para una tabla frente a 8.6 ms para esa misma tabla a través del backend gofpdf de Maroto.

Ambas formas vienen de la misma raíz: el coste por elemento en gpdf es casi plano, porque el bucle de layout no asigna memoria en el camino común. Veremos por qué.

Aviso breve que nadie quiere leer pero lo escribimos igual: la velocidad absoluta importa menos de lo que se piensa para la mayoría de cargas de PDF. Si tu documento más grande es un recibo de una página, cualquier biblioteca mantenida de esa tabla genera en el camino de la petición. El umbral que importa es "puedo generar 100 de éstos de forma síncrona sin encolar", y ahí empieza a abrirse el hueco.

Decisión 1: Sin AST intermedio

La mayoría de bibliotecas Builder de PDF funcionan así:

API Builder → árbol de documento (AST) → pasada de layout → serializador → bytes

El paso del árbol de documento es el problema. Cada llamada a .Text() asigna un nodo. Cada .Row() asigna un contenedor. La pasada de layout recorre el árbol para calcular posiciones. Después el serializador lo recorre otra vez para emitir bytes. Tres pasadas, tres conjuntos de asignaciones, tres vueltas sobre los mismos datos por la caché de CPU.

gpdf no tiene el paso 2. El Builder escribe directamente a un contexto de layout que escribe directamente al flujo de contenido. Una pasada.

Este es el camino de código concreto para un elemento de texto, recortado por espacio (la versión real está en 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)
}

Ningún nodo se mete en un árbol. Ninguna posición se difiere. El writer es un *pdf.Writer que mantiene un io.Writer (normalmente un bytes.Buffer), y BeginText / MoveTo / ShowString escriben los operadores PDF (BT, Td, Tj, ET) inmediatamente al buffer.

Compara cómo hace gofpdf la misma operación lógica. gofpdf mantiene un objeto page con un slice de operaciones. Cada llamada SetXY + Cell añade a ese slice. Output (o OutputFileAndClose) recorre el slice al final y emite los bytes. Son dos asignaciones por celda — una por la estructura de operación, otra por la copia de string — y una pasada extra sobre los datos.

Para un informe de 100 páginas con ~40 líneas por página, son 4,000 asignaciones extra que gpdf no hace.

Dónde duele la pasada única

La pregunta obvia: ¿cómo se hace lo que necesita conocer el layout final de página antes de empezar a emitir bytes? Cabeceras con números de página. Tablas que cruzan páginas. Pies de página anclados al final de la última línea del cuerpo.

Dos respuestas. Primera: bufferizamos la página actual, no el documento. Una página es una unidad acotada — decenas de KB, no megabytes. Cuando se ejecuta el siguiente AddPage(), el flujo de contenido de la página actual se cierra (Length, Filter, offsets), se escribe su entrada xref y el buffer de página se resetea. El pico de memoria se mantiene en O(una página).

Segunda: para elementos realmente globales ("Página 3 de 27"), diferimos ese rango específico a una pasada de fix-up. El resto del contenido ya está en el flujo. El fix-up recorre una lista corta de marcadores deferred-reference y parchea. Es el único sitio del código base donde pagamos algo parecido a un coste de AST, y solo lo pagamos para el contenido que lo necesita.

El intercambio: no puedes hacer post-procesado arbitrario sobre un árbol de nodos, porque no hay árbol. No puedes escribir un plugin que reordene "todos los nodos Text con bold: true". Si necesitas esa forma de API, Maroto v2 lo hace; gpdf no.

Creemos que es el intercambio correcto para los casos de uso que gpdf apunta. La mayoría de PDFs se producen de izquierda a derecha, de arriba a abajo, con un layout conocido en tiempo de construcción. El coste de mantener un AST para la minoría que lo necesita lo pagaba la mayoría en cada página. Invertimos esa relación.

Decisión 2: Sin reflexión, sin interface{} en el camino caliente

Escribir sobre esto es menos interesante que perfilarlo. Pero de aquí viene la otra mitad de la velocidad.

Mira la firma de CellFormat de gofpdf:

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

Vale. Ahora mira el árbol de componentes de Maroto. Un Row tiene []Component. Un Component es una interfaz. Cada operación de layout es un despacho virtual: component.Render(ctx). Para un único Col con un Text y un Spacer, son tres despachos. En un informe de 100 páginas con ~30 filas por página y ~3 componentes por fila, son ~9,000 despachos.

Individualmente, un despacho de interfaz en Go son ~2–3 ns. No es un crimen. Pero el despacho también obliga al compilador a mantener el valor boxeado en el heap — no puedes stack-allocate a través de una interfaz sin una pasada de devirtualización que el compilador de Go no siempre hace. Así que el coste no es solo el despacho; es la asignación que lo alimenta.

El motor de layout de gpdf usa structs concretos:

type RowBuilder struct {
    doc    *Document
    parent *pageState
    spans  [12]int
    cols   [12]ColBuilder  // valor, no puntero, no interfaz
    n      uint8
}

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

cols es un array de valores, dimensionado al número máximo de columnas (12, del sistema de grid). Sin asignación en heap. Sin despacho de interfaz cuando la fila itera sus columnas. El Builder mantiene un puntero al writer, no al revés — el writer no conoce el árbol del Builder.

El patrón de callback (r.Col(4, func(c *ColBuilder) { ... })) no es accidente. Todas las otras formas que prototipamos — una API que devuelve structs encadenables, un árbol de interfaces Component boxeadas — eran más lentas. El closure tiene cero asignaciones porque el ColBuilder es un valor que el llamante mantiene por puntero vía el parámetro; el propio closure se escape-analiza a la pila en el caso común.

Cómo sabemos que funcionó

go test -run=XXX -bench=BenchmarkSinglePage -memprofile=mem.out en gpdf da un número del que estamos orgullosos:

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

Cincuenta y dos asignaciones para una página PDF entera. Casi todas son el buffer inicial de página, la búsqueda de métricas de fuente (una vez por fuente, no una por glifo) y el crecimiento final del bytes.Buffer. El bucle de layout asigna cero — mira el perfil.

gofpdf en la misma página:

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

430 asignaciones. La mayoría son el slice de operaciones y las copias de string que lo alimentan. Mueve ese factor ~8 en asignaciones a través del GC, y el hueco de runtime de ~10× sale mecánicamente.

Qué cedimos

Cero ergonomía en el camino caliente significa menos puntos de extensión. Si quieres escribir un tipo de elemento personalizado que se enchufe al layout de gpdf — el equivalente a implementar Component en Maroto — no puedes. No hay interfaz que satisfacer. Lo que ofrecemos en su lugar es template.WithWriterSetup(), que da un hook al writer PDF para cosas como anotaciones personalizadas, metadatos PDF/A o cifrado. Para extensión de layout, lo escribes como un helper que llama a los mismos métodos Builder que un usuario llamaría.

Menos puntos de extensión es un coste real. Hemos decidido que vale la pena. Si la forma del proyecto cambia en una dirección donde no lo vale, lo revisaremos.

Decisión 3: Subconjuntado TrueType sin re-recorridos

Aquí es donde el benchmark CJK (133 µs frente a 254 µs de gofpdf) se lleva la mayor parte del hueco.

Resumen rápido de lo que hace el subconjuntado TrueType. Cuando embebes una fuente japonesa en un PDF, no quieres embeber sus 20,000+ glifos — son 15 MB de datos de fuente en un documento de 100 KB. Quieres embeber solo los glifos que tu documento realmente usa, empaquetados como un TTF subconjunto válido que un lector PDF pueda decodificar.

Para hacerlo:

  1. Parsear las tablas TTF completas: cmap (mapeo carácter→glifo), glyf (contornos), loca (offsets a glyf), hmtx (métricas horizontales), etc.
  2. Para cada carácter que usa el documento, buscar su ID de glifo vía el cmap.
  3. Recolectar transitivamente los glifos que los glifos compuestos referencian.
  4. Emitir un TTF nuevo con solo esos glifos, renumerados.

El paso 2 — la búsqueda en cmap — es el camino caliente. La implementación de gofpdf recorre la tabla cmap desde el principio en cada búsqueda de glifo. Para una página solo Latin va bien; el cmap es pequeño y la caché se porta. Para una página CJK con 150 glifos únicos son 150 recorridos completos de la tabla.

El formato 12 del cmap (usado por la mayoría de fuentes CJK modernas) es un array ordenado de triples (start, end, startGlyphID). Un recorrido es O(n) en el número de rangos, ~200–500 para NotoSansJP. 150 búsquedas de glifo × 400 rangos × comparación por rango = mucho más trabajo del necesario.

gpdf resuelve el cmap entero a un map[rune]uint16 en la primera carga de la fuente. A partir de ahí, cada búsqueda es O(1). Para NotoSansJP, el coste único es ~150 µs; después, 10 ns por carácter.

// Simplificado de pdf/font/ttf.go
type Font struct {
    runeToGID map[rune]uint16  // resuelto una vez al cargar
    glyphs    []glyph          // indexado por GID
    metrics   []glyphMetric
}

func (f *Font) GlyphFor(r rune) uint16 {
    return f.runeToGID[r]  // O(1), amable con caché, sin recorrido
}

Un mapa indexado por rune, poblado por una pasada lineal de la tabla cmap. Para un documento que usa la misma fuente en varias páginas (todas), esto mueve la búsqueda de glifos de "cuasi-cuadrática en páginas × glifos" a "lineal en glifos totales más una constante fija".

Por qué el "format 12" es el detalle que importa

La mayoría de las bibliotecas Go PDF antiguas se escribieron cuando el texto Latin era lo único que importaba, e implementaron el cmap format 4 — un rango segmentado para el Basic Multilingual Plane (U+0000–U+FFFF). El japonés fuera del BMP (menos común, pero algunas variantes Kanji) necesita format 12. El AddUTF8Font de go-pdf/fpdf hace panic en NotoSansJP-Regular.ttf porque su parser de format 12 no se terminó.

No es una crítica. Es un artefacto: gofpdf fue una gran biblioteca para lo que las webs centradas en Latin necesitaban en 2015, y el fork heredó su alcance. El mundo se movió; el CJK pasó de "el problema de otro" a "el problema de la mayoría de los ecosistemas Go de Japón y China". gpdf implementó la especificación cmap completa porque la alternativa era una factura que muestra cajas de tofu para 品目 — un bug reportado en la primera semana de release pública.

Caché que escala con número de fuentes, no con tamaño de documento

La caché de fuentes es por Document, no global. Si generas 10,000 PDFs con la misma fuente, pagas el coste de resolución de 150 µs 10,000 veces — a menos que compartas una instancia Font entre documentos, cosa que la API permite vía gpdf.WithSharedFont(preloadedFont).

Para generación en lotes de alto volumen (la SaaS gpdf-api funciona así), el patrón de fuente compartida es lo que hace predecible la latencia P95. Lo publicamos en los docs; la mayoría de usuarios OSS no lo necesita.

El efecto combinado

Pongamos las tres decisiones lado a lado en el benchmark de 100 páginas (683 µs para gpdf, 11.7 ms para gofpdf):

Origen del tiempogofpdf (por página, aprox)gpdf (por página, aprox)
Construcción del slice ops~60 µs0 (stream directo)
Serialización de ops~35 µs0 (ya escrito)
Búsquedas de glifo (40 chars)~6 µs~0.4 µs
Asignación / presión GC~20 µs~2 µs
Total~120 µs~7 µs

Los números son estimaciones de profiling; el desglose real depende del contenido. Pero la forma es correcta. Ninguno de los tres diseños gana 10× solo. Se suman.

Corolario: si copias solo un diseño a una biblioteca existente, ganas 2–3×. Si quieres el 10×, necesitas los tres, y no puedes retrofit el primero en una biblioteca basada en AST sin reescribirla.

Lo que cedimos (la sección honesta)

Hemos estado bailando alrededor. La lista completa:

Post-procesado basado en AST. Sin arquitectura de plugins. Sin "recorre el árbol de nodos y aplica esta transformación". Si quieres editar estilos de texto globalmente antes de renderizar, lo haces antes de llamar al Builder, no después.

Introspección. No hay doc.Components() que devuelva todo lo que pusiste. El documento es un flujo de operadores para cuando cualquier método significativo pueda correr. Para la mayoría de usuarios esto nunca aparece; para la minoría que escribe herramientas de manipulación de documentos, sí.

Serialización por reflexión. No tenemos una API estilo json.Unmarshal que convierta structs arbitrarios en PDF. El punto de entrada JSON Schema (template.FromJSON) es explícito sobre sus formas soportadas, a propósito. Si quieres apuntar una biblioteca a un struct Go genérico y obtener un PDF, eso es territorio de unidoc.

La extensibilidad de una interfaz. No puedes implementar Component y registrar un elemento personalizado. Puedes escribir una función helper que envuelve las llamadas al Builder, y en la práctica eso cubre el 95% de lo que pide la gente, pero es un modelo distinto.

Son deliberados. Cada uno individualmente mataría la velocidad. Elegimos el grupo de usuarios cuyo trabajo se beneficia de "rápido y opinionado" sobre el grupo que necesita "flexible y rico en plugins". Si estás en el segundo grupo, Maroto v2 o unidoc probablemente encajan mejor.

¿Puedo volver a correr el benchmark?

Sí. Ese es el propósito de publicar el código.

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

El README de ese directorio documenta las cuatro cargas y qué miden. Si tus números difieren materialmente (>20%) en la misma arquitectura de CPU y versión de Go, abre un issue — el drift es real y queremos saberlo.

Dos matices:

  • El benchmark corre con -benchmem. Si lo desactivas, los números mejoran ~5% en general, cosa que no contamos en afirmaciones públicas porque no es cómo nadie corre código real.
  • CGO está off. Algunos lectores han preguntado si un backend FreeType enlazado con CGO sería más rápido para operaciones de fuente; lo probamos, y el coste de marshaling a través de la frontera FFI dominó cualquier ganancia. El subconjuntador en Go puro gana para los patrones de acceso que tiene un generador PDF.

FAQ

¿Por qué comparar con gofpdf si está archivado? Porque sigue siendo el primer resultado de GitHub para "go pdf", y la mayoría de los equipos que llegan a gpdf están migrando desde allí. El benchmark necesita contestar "¿vale la pena la migración?" para esa audiencia. Versión corta: sí, y hemos escrito una guía de migración.

¿Ser 10× más rápido es realmente significativo para generar PDFs? Depende de la carga. Para un documento por petición de usuario, no mucho — ambas bibliotecas pasan el umbral de "generar en la petición". Para operaciones en lote (extractos nocturnos, facturas en masa, generación de informes desde queries a DB), el hueco se traduce directamente en menos máquinas. Oímos "10× menos workers" del primer equipo que migró su pipeline de lotes; no auditamos sus cuentas pero encaja con el benchmark.

¿Cuál es el truco del número CJK? Todavía tienes que enviar el archivo de fuente. gpdf lo subconjunta por ti, pero un NotoSansJP TTF de 3 MB son 3 MB que o embebes en tu binario Go o haces os.ReadFile al arranque. Para imágenes distroless esto importa. La SaaS gpdf-api lo soluciona enviando las fuentes comunes en la imagen; los usuarios OSS lo manejan ellos.

¿gpdf se volverá lento según se añadan features? Es la pregunta que más nos importa. Respuesta: hacemos benchmark de cada release frente a la anterior, y una regresión mayor al 5% en cualquiera de las cuatro cargas bloquea la release. Los benchmarks viven en el mismo repo que la biblioteca exactamente por esto.

¿De dónde viene el nombre? gpdf = Go + PDF. No es ingenioso. Intencionalmente.

Probar gpdf

gpdf es una biblioteca Go para generar PDFs. MIT, cero dependencias, CJK nativo.

go get github.com/gpdf-dev/gpdf

⭐ Star on GitHub · Leer los docs

Lecturas siguientes