Repository navigation
Cut per-call shaping cost with per-Font accelerators, plans and metric caches - #295
Merged
Merged
Conversation
HarfBuzz's map builder merges the entries of a global feature, and a later entry replaces the value of an earlier one, so liga=0,liga=1 turns ligatures on and kern=0,kern=1 kerns. sigilbuzz treated any zero entry as off. shape() now keeps each repeated tag at its last entry before any pass reads the list, and the disabled and enabled checks read the last entry too.
Face::glyph_points, which kerx anchors read, passed no vmtx for a font without one, so the top and bottom phantom points sat at (0, 0). The outline and bounds paths already use HarfBuzz's metrics for that case, a top side bearing of zero and an advance of an em, which put the points at (0, yMax) and (0, yMax - unitsPerEm).
Every shape() call parsed every subtable of every lookup it considered and walked the script list and language system once per feature query, so a short run paid for the whole font: one Noto Sans KR syllable took about 75 us against HarfBuzz's 4, most of it parsing and dropping the 268 chain-context subtables of one ccmp lookup. A Font now keeps, from its second shaping call on, what HarfBuzz keeps per face: - Per lookup, a set digest of the glyphs its subtables can start at and one per subtable (tables/layout/accel.rs), built from each subtable's primary coverage the first time a run reaches the lookup, under a per-lookup work budget so shared huge coverages cannot blow up. - Per combination of script tags, language and FeatureVariations record, the resolved language system (ot/layout_select.rs), at most 16 per table and each capped in features and lookups. Lookups whose digest misses every glyph of the run are skipped before any parsing or buffer setup, the cursor walk passes glyphs the digest rules out, and subtables are parsed only when a glyph their digest admits reaches them (shape/lazy.rs). A subtable that cannot cover the cursor glyph looks it up first and has no side effects, so skipping it changes nothing; the HarfBuzz subtable-cache rank is still counted over the subtables that parse. A font's first call builds none of this: it reads each lookup's coverages directly, so callers that build a Font per run are not charged for caches they never reuse. The caches are shared by clones, built through a lock-free OnceBox (sync.rs), and hold only values that are a pure function of the font data, so output stays deterministic and Font stays Send + Sync.
0.23.1 computes HarfBuzz's vertical origins for fonts without VORG from the glyph's ink extents, and advances of varied glyf fonts without HVAR or VVAR from phantom points. Both walk an outline (a CFF or CFF2 charstring, or gvar deltas), and both were recomputed on every call, which made repeated vertical runs 2x to 4x slower than in 0.23.0. A Font now keeps those values per glyph in direct-mapped caches, as HarfBuzz's hb_cache_t does: one atomic word per entry holding the glyph id and the value, so readers see a whole entry or none and need no lock. Each cache has at most 4096 entries (16 KiB) whatever the glyph count, values that do not fit an entry are not cached, and a font rebound with with_coords starts new caches while with_size keeps them. As with the layout caches, a font's first call builds none of it.
Shapes texts of nine fixtures, horizontally and vertically, with and without feature overrides, through one Font twice over and compares every call with a fresh Font's. Eight threads share one Font and walk the texts in different orders. A font rebound to other coordinates must not reuse the old instance's metrics, and Font and Face must stay Send + Sync.
Callers had to run fvar and avar by hand to bind a variable instance, and had to keep the normalized coords alive for the Font's lifetime because with_coords borrows them. oneiric does this by hand, and oniq's backend test skips the avar step. with_variations takes (tag, value) pairs as hb_font_set_variations does: axes start at their defaults, each pair sets every axis with its tag (a later pair wins), unknown tags are ignored, and the values go through the same fvar and avar steps with HarfBuzz's 16.16 rounding that shaping then rounds to F2DOT14. The Font owns the result, so Font::coords now returns a slice borrowed from the Font rather than one that lives for 'a. A face without a readable fvar gives the default instance, and an unreadable avar is left out, as HarfBuzz's sanitizer drops them.
Face::glyph_outline_at_coords parses the outline tables for every glyph: for a CFF2 font, the table, its variation store, the Private DICTs, and the region scalars of the coords, again for each glyph of a run. oneiric keeps its own Cff2 per run to avoid it, and loses the F2DOT14 rounding and VARC handling the face method has. GlyphOutlines keeps the table the face method would draw from (CFF2 with the shared state the shaper's extents already use, CFF, or glyf with loca, gvar and the phantom-point metrics) and VARC, read on first use. outline() returns what glyph_outline_at_coords returns for every glyph, errors included, and draw() streams the same ops into any OutlineSink. VARC composites are drawn by the face method, since their components carry coords of their own.
With the accelerators in place, merging a stage's lookups was the next per-call cost: for each feature of a stage, a query of the language system, a lookup list to collect, then a merge and a sort. The GPOS stage alone took about 2 us of a 7 us Segoe UI call, and the Indic shapers merge a dozen stages per run. A Font now keeps the merged lookups of each stage, keyed by the stage's kind and features (encoded as spec words), the caller's feature list, the script tags, the language, and the FeatureVariations record: the GPOS stage, the default and merged GSUB stages, and every stage of the syllabic shapers. A table keeps at most 64 plans, each with a key of at most 64 caller features and 256 spec words and at most 4096 lookups; anything larger is merged each time, as before. The stage merging code is unchanged and only runs when a plan is not kept. Also checks that every feature query through a kept language system answers as the walk does, over real fonts, scripts, languages and FeatureVariations records.
The Indic, Khmer, Myanmar and USE shapers built their syllable grammar from patterns and compiled it to an NFA on every call, about 25 us of a 40 us one-character Devanagari call. The grammars are constants, so each is now compiled the first time a run needs it and kept in a static OnceBox.
A syllabic stage built its glyph slots before looking at its lookups, and the Indic consonant-position test set up a scratch run for every dry run of a feature. When no lookup of the stage or feature can start at a glyph of the run, both leave the run as it is, so they now return early: the stage only sets the syllables its slots would have set. Stage plans also start probing at a slot their key hashes to, so the dozen stages of an Indic run each find their plan at the first slot.
The checks ahead of a lookup (in syllabic stages, masked features, dry runs and single lookups) are cheap with the font's digests, but in a font's first call they read every subtable's coverage, and the lookup then reads them again. A view without the font's cache now skips them and lets the lookup check itself, as 0.23.1 did, which keeps one-shot fonts with long runs from paying twice.
Coverage::index_of and contains run once or twice per glyph per subtable in every GSUB and GPOS walk. The lookup accelerators added callers, after which the inliner stopped inlining them into the PairPos path, and a kerned 100-glyph Roboto run took about 15% longer in GPOS than on main with the same GPOS code. Inlining them always restores it.
Each lookup's accelerator reads at most max(16384, 64 per subtable) coverage entries, but a hostile font can point every one of thousands of lookups at one huge coverage and make building them all read it again and again. The accelerators of a table now also share a budget of twice the table's length in entries (plus 64 Ki), spent with a compare-and-swap so threads cannot overdraw it. Past it, a subtable's digest admits every glyph, as past the per-lookup budget, so the work stays linear in the size of the font.
The shape target now binds half its fonts from user-space axis values, and the face target checks that a GlyphOutlines run draws every glyph exactly as glyph_outline_at_coords does, errors included.
# Conflicts: # docs/STABILITY.md
albedosehen
marked this pull request as ready for review
October 4, 2026 20:45
This was referenced Oct 4, 2026
Merged
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.
Closes #289
Cuts sigilbuzz's per-call shaping cost (N2), brings repeated vertical runs of fonts without
VORGback to 0.23.0 speed or better (B8), adds a user-space variations constructor and a parse-once outline API (N4), and fixes repeated feature tags (B2) andglyph_pointsphantoms withoutvmtx(B10). Shaping output is byte-identical to main everywhere except the B2 and B10 cases, which now match HarfBuzz 14.5.0.Design
What a
Fontkeeps (src/font/cache.rs)A
Fontnow carriesFontCaches: two lazily built parts, each anArcbehind aOnceBox, shared by every clone made after they are built.FaceCache(font data only; kept bywith_coordsandwith_size). For each of GSUB and GPOS, aLayoutCache(src/tables/layout/accel.rs) with:FeatureMap,src/ot/layout_select.rs): per (script tags, language tags, FeatureVariations record), every feature query answered from one pass over the language system instead of a script-list and language-system walk per query. A unit test checks every query against the walk over real fonts, scripts, languages and FeatureVariations records.StagePlans): the merged, sorted lookups of each GPOS stage, default/merged GSUB stage, and syllabic-shaper stage, keyed by the stage spec, the caller's feature list, script tags, language and record. The merging code is unchanged and only runs on a miss.InstanceCache(also depends on coords; kept bywith_size, new forwith_coords/with_variations): direct-mapped per-glyph caches (hb_cache_tstyle) of vertical origins, phantom-point vertical advances and phantom-point horizontal advances: oneAtomicU32per entry holdinggid << 16 | i16 value.How shaping uses them:
src/shape/lazy.rs), only when a glyph their digest admits reaches them (Noto Sans KR'sccmphas 268 chain-context subtables; a syllable reaches one or two). HarfBuzz's subtable-cache rank (SUBTABLE_CACHES) is still counted over the subtables that parse.Fontper run (oniq's backend does) would pay more for building digests and maps than one call saves, so the first call reads each lookup's coverages directly (DirectAccel, inline storage for small lookups) and walks the language system as before; from the second call on, everything is built on first need. The "fresh Font per call" table below shows the one-shot path.Also: the Indic, Khmer, Myanmar and USE syllable grammars were compiled from patterns to an NFA on every call (about 25 us of a 40 us one-character Devanagari call); each is now compiled once into a
static OnceBox.Coverage::index_of/containsare#[inline(always)]: once the accelerators added callers, the inliner stopped inlining them into the PairPos path and a kerned 100-glyph Roboto run lost about 15% in GPOS with identical GPOS code (bisected in one process against main).Thread safety and determinism
OnceBox<T>(src/sync.rs, the crate's firstunsafe, about 40 lines withSAFETYcomments, modeled ononce_cell::race::OnceBox): anAtomicPtr<T>set once by compare-and-swap fromBox::into_raw, freed only inDrop(&mut self).SendforT: Send,SyncforT: Send + Sync. Racing initializers may both build; the loser drops its copy. Unit tests cover single drop and racing threads.Relaxed): a reader sees a whole entry or none.InstanceCache), so whichever thread's value wins is the value any other would compute, and output never depends on cache state, warmth, eviction or thread interleaving. The per-table digest budget below can make one lookup's digest exact or "admit everything" depending on build order; both give identical output.Font,FaceandBlobstaySend + Sync(compile-time test). The core crate staysno_std+allocwith no dependencies; it needs pointer-width atomics with CAS, whichalloc::sync::Arc(already used byOwnedFace) needs too.Memory bounds
Per font and table: one pointer per lookup; 24 bytes per subtable of each lookup a run reached; at most 16 resolved language systems (each at most 1024 features and 16384 lookup indices, else not kept); at most 64 stage plans (keys of at most 64 caller features and 256 spec words, at most 4096 lookups, else not kept). Glyph caches: at most 4096 four-byte entries each (16 KiB), three of them, whatever the glyph count. Measured heap bytes of a warm
Fontafter shaping 20 x 100-character texts in both directions (counting allocator):The compiled syllable machines are process-wide statics (about 9 KB for the Indic one).
Hostile input
LookupBudgetlimits are unchanged.rustlang/rust:nightly, cargo-fuzz 0.13.2,-O -a,-max_len=65536 -rss_limit_mb=2048 -timeout=10, seeds fromtests/fixturesandtests/fonts), 10 minutes each on the final code:shape164,331 runs (cov 15297, peak RSS 734 MB),face587,494 runs (cov 5777, peak RSS 648 MB), no crashes, timeouts or OOMs. Theshapetarget now binds half its fonts throughwith_variations, andfaceasserts thatglyph_outlinesdraws every glyph exactly asglyph_outline_at_coordsdoes.Other items
shape()keeps each repeated tag at its last entry before any pass reads the feature list, and the disabled/enabled checks read the last entry, as HarfBuzz's map builder merges global features. Calibri "office" withliga=0,liga=1now gets the ffi ligature (312) and "AVAT" withkern=0,kern=1is kerned (1096,1066,1025,998), as in HarfBuzz.Face::glyph_pointspasses HarfBuzz's metrics for a font withoutvmtx(Vmtx::missing(upem)), so the vertical phantoms are (0, yMax) and (0, yMax - upem). The existing Open Sans test expected (0, 0) and now expects HarfBuzz's values.Font::with_variations(&[(*b"wght", 650.0)]): axes default, each pair sets every axis with its tag (later wins), unknown tags ignored, values clamped, then the existingnormalize_coords+remap_allsteps (16.16, then F2DOT14 at shaping). Matcheshb_font_set_variationson 4,200 random settings over every variable font at hand (out-of-range values, repeated and unknown tags included): 0 differ.Face::glyph_outlines(coords) -> GlyphOutlineskeeps CFF2 (with the shared variation store, Private DICTs and region scalars the shaper's extents already use), CFF, or glyf/loca/gvar/hmtx/vmtx, plus VARC;outline(gid)equalsglyph_outline_at_coordsfor every glyph (errors included) anddraw(gid, sink)streams into anyOutlineSink.API changes
Font::with_variations,Face::glyph_outlines,GlyphOutlines::{outline, draw}(root export), documented indocs/STABILITY.mdunder "Added in 0.24.0".Font::coordsis nowpub fn coords(&self) -> &[f32](waspub const fn coords(&self) -> &'a [f32]). A font built withwith_variationsowns its coords, so the slice can only borrow the font. Code that used the returned slice after dropping theFontneeds to copy it; nothing in the workspace did.Font::newstaysconst. Justified for a minor release: the alternative was leaving the user-space constructor to callers, which is the ask in N4.Fontclones made after a font's first call share its caches; documented onFont.Speed
Release builds (
lto = "thin",codegen-units = 1), one Windows 11 laptop (i9-11900H) shared with other jobs. sigilbuzz 0.23.0, 0.23.1 (main, fce2d02) and this branch are linked into one binary and measured in alternating batches in the same process, by thread cycle time, median of 15 batches of about 10 ms, best of 2 runs; each row shapes 20 texts of 1, 8 or 100 characters (Hangul syllables, Latin letters and spaces, Arabic letters, Devanagari words) withguess_segment_properties(so "LTR" rows are horizontal, right to left for Arabic) or TTB, no features. HarfBuzz is uharfbuzz 0.56.2 (HarfBuzz 14.5.0), the median of 15 wall-clock batches from Python, best of 2, so its numbers include about 0.5 to 1 us of Python per call. Times are microseconds per call.Repeated calls on one
Font(the case the caches are for), selected rows:B8, repeated vertical calls against 0.23.0 (fonts without
VORG):Horizontal runs are no slower than 0.23.0 or 0.23.1 in any row of the full table (Source Code Pro is within noise of both).
Face::glyph_outlinesagainstglyph_outline_at_coordsper glyph, 100 consecutive glyphs: Source Serif 4 VF (CFF2) 5.12 to 3.74 us (wght 700: 5.29 to 3.68), Noto Sans KR VF otf 2.98 to 2.09 us, Source Code Pro (CFF) 0.75 to 0.44 us.Full table: repeated calls on one Font
Full table: a fresh Face and Font for every call (the first-call path, as oniq's backend shapes)
Verification
shape_cmp.pyfrom the Close variable-font metric parity gaps with HarfBuzz 14.5.0 #283 review (22 fonts, 1 to 5 axis settings each, 95,035 texts per direction), seeds 99 and 7, both directions: 0 changed (and 0 new mismatches against HarfBuzz).tests/fonts, Noto Sans Devanagari through Tibetan, Myanmar, Khmer, Sinhala and Javanese, plus Segoe UI, Arial, Calibri, Tahoma, Roboto, Georgia, Sitka VF at 5 settings, Consolas, Ebrima, Gadugi, Microsoft Himalaya and Leelawadee), 47,277 texts per direction, seeds 99 and 7: 0 changed.Fontper font and setting (warm caches) and with a freshFontfor every text (the first-call path): 0 changed either way.liga=0,kern=0,smcp,salt=2,ss01,onum,frac,vert=1,ccmp=0,aalt=2, and repeated-tag lists) over 7 fonts: only the repeated-tag lists changed (159 runs). On those lists this branch differs from HarfBuzz on 0 of 1,600 runs, main on 159.officewithliga=0,liga=1,AVATwithkern=0,kern=1and reversed): all four match HarfBuzz.tests/font_caches.rs(9 fixtures, both directions, three feature lists, oneFontshaped twice over against a freshFontper call; 8 threads sharing oneFontin different orders;with_coordsandwith_sizecache sharing;Send + Sync),tests/glyph_outlines.rs(every glyph of 7 fonts at 6 settings in any order, sinks, a broken CFF2),tests/repeated_features.rs, and unit tests forOnceBox, digests, accelerators and both budgets, feature maps against the walk, stage plans, glyph caches,with_variations.cargo fmt --all -- --check;cargo +1.99.0 clippy --workspace --all-targets --all-features -- -D warnings; the same with--no-default-features;cargo +1.99.0 clippy -p sigilbuzz --no-default-features -- -D warnings;cargo test --workspace --all-features;cargo build --no-default-features; MSRV afterCARGO_RESOLVER_INCOMPATIBLE_RUST_VERSIONS=fallback cargo +stable generate-lockfile:cargo +1.81 check --workspace --lib --no-default-featuresandcargo +1.81 check --workspace --lib --bins --all-features. All pass.shapeandface, 10 minutes each in Docker, no findings (details above).Not done
hb_shape_planstyle). The caches live inFontinstead, so existingshape(&font, ...)callers get them by keeping theirFont. A caller that builds aFontper run gets only the first-call improvements: oniq's sigilbuzz backend parses theFaceand builds theFontinshape_run, so it should keep oneFontper font and size to get the rest.Font(first call) with a long kerned run can be a few percent slower than main (Roboto LTR 100: 53.44 to 56.47 us, within the noise of other rows); every other first-call row is equal or faster.sigilbuzz-capi'shb_font_set_variationsstill normalizes by hand and sets only the first axis with a repeated tag; it could callFont::with_variationsin a follow-up.Cff2workaround can move toFace::glyph_outlines; the UAX 50 vertical-orientation helper from N4 is not part of this PR.docs/PERFORMANCE.mdstill holds the 0.6.0 Criterion numbers.CHANGELOG lines (0.24.0)
Added:
Font::with_variationsbinds a variable instance from user-space axis values, as HarfBuzz'shb_font_set_variationsdoes: each(tag, value)sets every axis with that tag, a later pair wins, unknown tags are ignored, and the values go throughfvarandavarwith HarfBuzz's rounding (Cut the per-call shaping cost and the vertical-run slowdown #289, Cut per-call shaping cost with per-Font accelerators, plans and metric caches #295). The font owns the coords it computes.Face::glyph_outlinesreturns aGlyphOutlinesthat draws many glyphs at one instance with the outline tables read once, theCFF2table with its variation store, Private DICTs and region scalars included (Cut the per-call shaping cost and the vertical-run slowdown #289, Cut per-call shaping cost with per-Font accelerators, plans and metric caches #295). Each glyph comes out asFace::glyph_outline_at_coordsdraws it;drawstreams into anyOutlineSink.Changed:
Fontkeeps what HarfBuzz keeps per face and font, from its second shaping call on: per-lookup glyph digests, so lookups and subtables that cannot apply are skipped unparsed; the language system each script, language and FeatureVariations record resolves to; the merged lookups of each shaping stage; and per-glyph vertical origins and phantom-point advances (Cut the per-call shaping cost and the vertical-run slowdown #289, Cut per-call shaping cost with per-Font accelerators, plans and metric caches #295). One Noto Sans KR syllable shapes in about 4 us instead of 62, one Segoe UI character in 3 instead of 9, one Devanagari character in 13 instead of 400. Clones share the caches, which are bounded per font (at most 48 KiB of glyph caches whatever the glyph count) and thread-safe without locks. A font's first call builds none of it, and subtables are parsed only when a glyph reaches them there too.VORGare back to 0.23.0's speed or faster: a CFF run within about 6%, CFF2 and variedglyfruns 25% to 50% faster (Cut the per-call shaping cost and the vertical-run slowdown #289, Cut per-call shaping cost with per-Font accelerators, plans and metric caches #295).Font::coordsreturns a slice that borrows the font, since a font built withwith_variationsowns its coords (Cut the per-call shaping cost and the vertical-run slowdown #289, Cut per-call shaping cost with per-Font accelerators, plans and metric caches #295).Fixed:
liga=0,liga=1ligates andkern=0,kern=1kerns (Cut the per-call shaping cost and the vertical-run slowdown #289, Cut per-call shaping cost with per-Font accelerators, plans and metric caches #295). Any zero entry used to turn the feature off.Face::glyph_points, whichkerxanchors read, gives a font withoutvmtxHarfBuzz's vertical phantom points, (0, yMax) and (0, yMax - unitsPerEm), as the outline and bounds methods do; they were (0, 0) (Cut the per-call shaping cost and the vertical-run slowdown #289, Cut per-call shaping cost with per-Font accelerators, plans and metric caches #295).