Repository navigation
perf: share parsed fonts across renders; Engine.FontFaces for host typefaces - #291
Merged
Merged
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-facedata: URIs. Profiling its export against marp-cli showed most of each render going to fonts:paint.NewFontsparsed the seven bundled families on every render;@font-facefile was parsed twice per render (LoadFontFacesvalidates it,RegisterFontFacesparses it again);urlToken,formatHintand the!importantcheck lowercased a copy of the whole value — for an inlined font or image, hundreds of kilobytes copied per declaration per render.What
*opentype.Fontis read-only afterParse(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 perFonts.Fonts.RegisterFontregisters an already-parsed font.LoadFontFaceskeeps the font it parsed to validate a file;RegisterFontFacesregisters it instead of parsing again.Engine.FontFaces— typefaces the host installs for every render, like a browser's system fonts — andParseFontFaceto build one, parsed once. A family the page declares a face for keeps the page's.indexFold/equalFoldASCII), which also keeps the index valid when a non-ASCII character changes length underToLower(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!importantcases.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: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.FontFacesin place of the data: URIs; output byte-identical.🤖 Generated with Claude Code