Skip to content

perf: share parsed fonts across renders; Engine.FontFaces for host typefaces - #291

Merged
tannevaled merged 1 commit into
mainfrom
perf/font-cache
Oct 18, 2026
Merged

tannevaled merged 1 commit into
mainfrom
perf/font-cache

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

Why

go-slides renders a deck one page per slide, and its bundled theme fonts (Lato, Roboto Mono, five faces) travelled in every page as @font-face data: URIs. Profiling its export against marp-cli showed most of each render going to fonts:

  • paint.NewFonts parsed the seven bundled families on every render;
  • each @font-face file was parsed twice per render (LoadFontFaces validates it, RegisterFontFaces parses it again);
  • urlToken, formatHint and the !important check lowercased a copy of the whole value — for an inlined font or image, hundreds of kilobytes copied per declaration per render.

What

  • paint: the bundled families are parsed once per process and shared. An *opentype.Font is read-only after Parse (checked: every field write in go-opentype is in the parse path); the per-registry state — the Face cache that caches glyphs, the named faces — stays per Fonts.
  • paint: Fonts.RegisterFont registers an already-parsed font.
  • engine: LoadFontFaces keeps the font it parsed to validate a file; RegisterFontFaces registers it instead of parsing again.
  • engine: parsed font files are cached across renders, keyed by the SHA-256 of their bytes (never by URL, so a key only ever names the bytes it was parsed from), bounded to 64 files / 32 MiB, LRU. A file larger than the bound is used and not kept; a file that fails to parse is not kept.
  • engine: Engine.FontFaces — typefaces the host installs for every render, like a browser's system fonts — and ParseFontFace to build one, parsed once. A family the page declares a face for keeps the page's.
  • css: the three searches match in place (the existing indexFold / equalFoldASCII), which also keeps the index valid when a non-ASCII character changes length under ToLower (tested with Ⱥ).

Tests

  • TestSharedFontsRenderConcurrently: 6 concurrent renders on one Engine sharing a cached data: URI face and the bundled fonts, under -race, byte-identical to a serial render.
  • TestEngineFontFaces: a host face renders byte for byte like the same file declared by the page; the page's own face wins; an unparseable host face is skipped.
  • TestFontCacheHitsAndBounds, TestFontCacheConcurrentMiss, TestRegisterFontFacesUsesParsedFont, TestNewFontsSharesBundledFonts, TestRegisterFont, TestParseFontFacesSrcCase, and new !important cases.
  • Coverage: paint 100.0%, css 99.5% (floors unchanged).

Measured

BenchmarkRenderInlineFontFace (a 1280×720 page carrying its face as a data: URI), Apple M4 Max, 3 runs each, base = origin/main in a separate worktree:

time/op bytes/op allocs/op
before 50–54 ms 36.6 MB 73,160
after 12–15 ms 6.2 MB 1,160

The machine was loaded (load average ~40 on 16 cores) while measuring: the allocation figures are exact, the times are indicative.

In go-slides (10-slide gaia deck, one worker): 1,211 MB → 65 MB allocated, with Engine.FontFaces in place of the data: URIs; output byte-identical.

🤖 Generated with Claude Code

…pefaces

A render parsed every font it used from scratch: the seven bundled families
(paint.NewFonts, once per render) and every @font-face file twice (once in
LoadFontFaces to validate it, once more in RegisterFontFaces). A document
that carries its typefaces as data: URIs — a slide deck exported to render
offline, one render per slide — paid for all of it on every page.

- paint: the bundled families are parsed once per process and shared. An
  *opentype.Font is read-only once parsed; the per-registry state (the Face
  cache, the named faces) stays per Fonts.
- paint: Fonts.RegisterFont registers an already-parsed font.
- engine: LoadFontFaces keeps the font it parsed to validate a file, and
  RegisterFontFaces registers that instead of parsing again.
- engine: parsed font files are kept across renders in a cache keyed by
  the SHA-256 of their bytes (never by URL), bounded to 64 files and
  32 MiB of them, least recently used out first; a file larger than the
  bound is used and not kept, a file that fails to parse is not kept.
- engine: Engine.FontFaces are typefaces the host installs for every
  render, as a browser's system fonts; ParseFontFace builds one, parsed
  once. A family the page declares a face for keeps the page's. A host
  that inlined the same fonts into every page as data: URIs no longer
  makes each render read, decode and parse them.
- css: urlToken, formatHint and the !important check in ParseDeclarations
  searched a lowercased copy of the whole value — for an inlined font or
  image, hundreds of kilobytes copied per declaration per render. They now
  match in place, which also keeps the index valid when a non-ASCII
  character changes length under ToLower.

Concurrent renders on one Engine sharing these fonts are race-free (go
test -race) and match a serial render byte for byte; a host face renders
byte for byte like the same file declared by the page.

BenchmarkRenderInlineFontFace (a 1280x720 page carrying its face as a
data: URI), Apple M4 Max, 3 runs each:

  before  50-54 ms/op  36.6 MB/op  73,160 allocs/op
  after   12-15 ms/op   6.2 MB/op   1,160 allocs/op

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@tannevaled
tannevaled merged commit 4fb3ca0 into main Oct 18, 2026
7 checks passed
@tannevaled
tannevaled deleted the perf/font-cache branch October 18, 2026 14:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant