Skip to content

[font] cache the un-compressed SVG document and its viewBox - #294

Merged
benoitkugler merged 1 commit into
go-text:mainfrom
egonelbre:perf/svg-cache
Oct 1, 2026
Merged

benoitkugler merged 1 commit into
go-text:mainfrom
egonelbre:perf/svg-cache

Conversation

@egonelbre

Copy link
Copy Markdown
Contributor

Every GlyphDataSVG call gunzipped the shared document again and ran an
xml.Decoder over it to find the viewBox. Each svgDocument now resolves
both once, under a sync.Once because the table lives on the Font, which
many goroutines share.

resolve only gunzips a document that starts with the gzip magic bytes,
and rejects one whose stream is truncated or fails its checksum. For a
rejected document glyphData returns false instead of the raw bytes.
GlyphSVG.Source is documented as read-only, since every glyph of a
document now shares it. TestSVGResolveConcurrent covers plain, gzipped
and corrupt documents resolved from several goroutines at once.

BenchmarkGlyphDataSVG (Apple M4 Max, count=6):

old: 1885ns ± 7% 2488B/op 26 allocs/op
new: 108ns ± 3% 416B/op 4 allocs/op -94.3% time

@benoitkugler benoitkugler left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice change !

I have a minor suggestion, but this looks good overall (pending merge conflicts resolution).

Comment thread font/svg.go Outdated
)

type svg []svgDocument
type svg []*svgDocument

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we avoid pointer indirection here ? Since svg is a slice, its elements are adressable anyway right ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason it was using a pointer was due to sync.Once; which usually expects to be attached to a pointer struct. Otherwise when the slice is appended and items copied, it could break. Here it's safe to use, because the slice is built only once.

The alternative would be to have the resolved data as atomic.Pointer and then just have rarely two goroutines race to write it.

Anyways, changed it to []svgDocument for now.

Every GlyphDataSVG call gunzipped the shared document again and ran an
xml.Decoder over it to find the viewBox. Each svgDocument now resolves
both once, under a sync.Once because the table lives on the Font, which
many goroutines share.

resolve only gunzips a document that starts with the gzip magic bytes,
and rejects one whose stream is truncated or fails its checksum. For a
rejected document glyphData returns false instead of the raw bytes.
GlyphSVG.Source is documented as read-only, since every glyph of a
document now shares it. TestSVGResolveConcurrent covers plain, gzipped
and corrupt documents resolved from several goroutines at once.

BenchmarkGlyphDataSVG (Apple M4 Max, count=6):

  old: 1885ns ± 7%   2488B/op   26 allocs/op
  new:  108ns ± 3%    416B/op    4 allocs/op   -94.3% time

@benoitkugler benoitkugler left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice optimization, thank you !

@benoitkugler
benoitkugler merged commit 7f66441 into go-text:main Oct 1, 2026
7 checks passed
@egonelbre
egonelbre deleted the perf/svg-cache branch October 1, 2026 13:12
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.

2 participants