Skip to content

Cut per-call shaping cost with per-Font accelerators, plans and metric caches - #295

Merged
albedosehen merged 16 commits into
mainfrom
perf/shaping-cache
Oct 4, 2026
Merged

albedosehen merged 16 commits into
mainfrom
perf/shaping-cache

Conversation

@albedosehen

@albedosehen albedosehen commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

Closes #289

Cuts sigilbuzz's per-call shaping cost (N2), brings repeated vertical runs of fonts without VORG back 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) and glyph_points phantoms without vmtx (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 Font keeps (src/font/cache.rs)

A Font now carries FontCaches: two lazily built parts, each an Arc behind a OnceBox, shared by every clone made after they are built.

  • FaceCache (font data only; kept by with_coords and with_size). For each of GSUB and GPOS, a LayoutCache (src/tables/layout/accel.rs) with:
    • Lookup accelerators: per lookup, a HarfBuzz-style set digest (three 64-bit masks, shifts 0/4/9) of the glyphs its subtables can start at, plus one digest per subtable, built from each subtable's primary coverage (the first input coverage of a format 3 context) the first time the lookup is applied. Every subtable looks the cursor glyph up in that coverage first and has no side effects when it is not there, so skipping is output-neutral.
    • Resolved language systems (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.
    • Stage plans (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 by with_size, new for with_coords/with_variations): direct-mapped per-glyph caches (hb_cache_t style) of vertical origins, phantom-point vertical advances and phantom-point horizontal advances: one AtomicU32 per entry holding gid << 16 | i16 value.

How shaping uses them:

  • A lookup whose digest misses every glyph of the run is skipped before any parse, buffer, mask or slot setup (GSUB stages, masked features, Arabic joining features, syllabic stages, Indic dry runs, GPOS).
  • The cursor walk passes glyphs the lookup digest rules out, and subtables are parsed lazily (src/shape/lazy.rs), only when a glyph their digest admits reaches them (Noto Sans KR's ccmp has 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.
  • A font's first shaping call builds nothing. Callers that build a Font per 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/contains are #[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 first unsafe, about 40 lines with SAFETY comments, modeled on once_cell::race::OnceBox): an AtomicPtr<T> set once by compare-and-swap from Box::into_raw, freed only in Drop (&mut self). Send for T: Send, Sync for T: Send + Sync. Racing initializers may both build; the loser drops its copy. Unit tests cover single drop and racing threads.
  • Glyph caches are single-word atomic entries (Relaxed): a reader sees a whole entry or none.
  • Every cached value is a pure function of the font data (and coords for 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, Face and Blob stay Send + Sync (compile-time test). The core crate stays no_std + alloc with no dependencies; it needs pointer-width atomics with CAS, which alloc::sync::Arc (already used by OwnedFace) 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 Font after shaping 20 x 100-character texts in both directions (counting allocator):

Font Glyphs Cache bytes
Malgun Gothic padded to 65,535 glyphs (glyf, vmtx, no VORG), Korean 65,535 21,056
Noto Sans KR VF otf, wght 700 24,964 31,264
Noto Sans KR VF ttf, wght 700 24,964 47,664
Hahmlet, wght 700 3,998 24,176
Source Serif 4 VF, wght 700 1,464 14,284
Segoe UI 5,394 24,516
Amiri 6,710 38,124
Noto Sans Devanagari 845 33,862

The compiled syllable machines are process-wide statics (about 9 KB for the Indic one).

Hostile input

  • Digest building reads each subtable's primary coverage once under two budgets: per lookup max(16384, 64 per subtable) entries, and per table 2 x table length + 64 Ki entries shared by all its lookups (compare-and-swap). Past either, the subtable's digest admits every glyph (correct, just no skipping), so a font that points thousands of lookups or subtables at one huge coverage costs at most a few reads of its own table.
  • Maps, plans and glyph caches have the fixed caps above; keys or results past them are computed per call as before.
  • Lazy parsing only ever parses fewer subtables than before; LookupBudget limits are unchanged.
  • Fuzzing in Docker (rustlang/rust:nightly, cargo-fuzz 0.13.2, -O -a, -max_len=65536 -rss_limit_mb=2048 -timeout=10, seeds from tests/fixtures and tests/fonts), 10 minutes each on the final code: shape 164,331 runs (cov 15297, peak RSS 734 MB), face 587,494 runs (cov 5777, peak RSS 648 MB), no crashes, timeouts or OOMs. The shape target now binds half its fonts through with_variations, and face asserts that glyph_outlines draws every glyph exactly as glyph_outline_at_coords does.

Other items

  • B2: 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" with liga=0,liga=1 now gets the ffi ligature (312) and "AVAT" with kern=0,kern=1 is kerned (1096,1066,1025,998), as in HarfBuzz.
  • B10: Face::glyph_points passes HarfBuzz's metrics for a font without vmtx (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.
  • N4: 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 existing normalize_coords + remap_all steps (16.16, then F2DOT14 at shaping). Matches hb_font_set_variations on 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) -> GlyphOutlines keeps 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) equals glyph_outline_at_coords for every glyph (errors included) and draw(gid, sink) streams into any OutlineSink.

API changes

  • Added: Font::with_variations, Face::glyph_outlines, GlyphOutlines::{outline, draw} (root export), documented in docs/STABILITY.md under "Added in 0.24.0".
  • Changed: Font::coords is now pub fn coords(&self) -> &[f32] (was pub const fn coords(&self) -> &'a [f32]). A font built with with_variations owns its coords, so the slice can only borrow the font. Code that used the returned slice after dropping the Font needs to copy it; nothing in the workspace did. Font::new stays const. Justified for a minor release: the alternative was leaving the user-space constructor to callers, which is the ask in N4.
  • Behavior: Font clones made after a font's first call share its caches; documented on Font.

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) with guess_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:

Font Run 0.23.1 this branch HarfBuzz
Noto Sans KR VF otf 1 syllable, LTR 61.61 3.71 1.13
Noto Sans KR VF otf 8 syllables, LTR 74.59 8.41 1.82
Noto Sans KR VF ttf 1 syllable, LTR 61.39 3.47 1.27
Segoe UI 1 character, LTR 8.85 3.05 1.47
Segoe UI 8 characters, LTR 12.99 4.47 2.32
Roboto 1 character, LTR 6.17 3.06 1.29
Source Serif 4 VF (CFF2) 8 characters, LTR 14.91 7.37 2.41
Amiri 1 letter, RTL 71.49 11.21 1.63
Noto Sans Devanagari 1 character 402.46 12.67 1.98
Noto Sans Devanagari 8 characters 464.68 36.29 5.38

B8, repeated vertical calls against 0.23.0 (fonts without VORG):

Font Chars 0.23.0 0.23.1 this branch branch / 0.23.0
Source Code Pro (CFF) 8 2.18 4.64 2.21 1.01
Source Code Pro (CFF) 100 10.30 24.06 10.88 1.06
Source Serif 4 VF (CFF2), default 8 11.03 33.60 5.35 0.49
Source Serif 4 VF (CFF2), default 100 51.12 177.47 29.11 0.57
Source Serif 4 VF (CFF2), wght 700 8 14.90 40.08 9.06 0.61
Source Serif 4 VF (CFF2), wght 700 100 105.07 269.40 78.33 0.75
Hahmlet (glyf, no vmtx), wght 700 8 10.06 40.80 6.18 0.61
Hahmlet (glyf, no vmtx), wght 700 100 64.21 489.52 47.79 0.74

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_outlines against glyph_outline_at_coords per 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
Font Axes Dir Chars 0.23.0 0.23.1 this branch 0.23.1 / branch HarfBuzz
Noto Sans KR VF otf (CFF2, VORG) default LTR 1 62.33 61.61 3.71 16.6x 1.13
Noto Sans KR VF otf (CFF2, VORG) default LTR 8 75.92 74.59 8.41 8.9x 1.82
Noto Sans KR VF otf (CFF2, VORG) default LTR 100 250.49 238.29 49.58 4.8x 20.51
Noto Sans KR VF otf (CFF2, VORG) default TTB 1 56.77 57.62 3.50 16.5x 1.85
Noto Sans KR VF otf (CFF2, VORG) default TTB 8 90.59 88.01 8.42 10.5x 3.08
Noto Sans KR VF otf (CFF2, VORG) default TTB 100 251.05 234.60 47.78 4.9x 25.30
Noto Sans KR VF otf (CFF2, VORG) wght=700 LTR 1 64.88 64.94 4.19 15.5x 1.90
Noto Sans KR VF otf (CFF2, VORG) wght=700 LTR 8 81.07 77.61 10.56 7.4x 2.29
Noto Sans KR VF otf (CFF2, VORG) wght=700 LTR 100 311.85 296.40 102.22 2.9x 19.61
Noto Sans KR VF otf (CFF2, VORG) wght=700 TTB 1 63.64 63.48 4.35 14.6x 1.63
Noto Sans KR VF otf (CFF2, VORG) wght=700 TTB 8 76.54 78.90 9.64 8.2x 2.61
Noto Sans KR VF otf (CFF2, VORG) wght=700 TTB 100 296.70 278.31 75.11 3.7x 30.32
Noto Sans KR VF ttf (glyf, vmtx) default LTR 1 59.61 61.39 3.47 17.7x 1.27
Noto Sans KR VF ttf (glyf, vmtx) default LTR 8 70.79 70.19 7.86 8.9x 1.85
Noto Sans KR VF ttf (glyf, vmtx) default LTR 100 239.16 229.46 48.36 4.7x 16.01
Noto Sans KR VF ttf (glyf, vmtx) default TTB 1 54.89 54.87 3.44 15.9x 1.93
Noto Sans KR VF ttf (glyf, vmtx) default TTB 8 86.83 85.38 8.02 10.7x 2.44
Noto Sans KR VF ttf (glyf, vmtx) default TTB 100 244.94 256.31 50.47 5.1x 27.33
Noto Sans KR VF ttf (glyf, vmtx) wght=700 LTR 1 61.12 61.65 3.93 15.7x 1.24
Noto Sans KR VF ttf (glyf, vmtx) wght=700 LTR 8 74.25 75.06 9.54 7.9x 1.88
Noto Sans KR VF ttf (glyf, vmtx) wght=700 LTR 100 305.39 289.81 98.75 2.9x 22.09
Noto Sans KR VF ttf (glyf, vmtx) wght=700 TTB 1 68.17 71.90 4.58 15.7x 2.08
Noto Sans KR VF ttf (glyf, vmtx) wght=700 TTB 8 80.56 85.26 9.71 8.8x 3.62
Noto Sans KR VF ttf (glyf, vmtx) wght=700 TTB 100 403.92 387.23 119.87 3.2x 62.87
Hahmlet (glyf, no vmtx) wght=700 LTR 1 12.80 12.70 3.57 3.6x 1.63
Hahmlet (glyf, no vmtx) wght=700 LTR 8 17.63 17.32 6.60 2.6x 1.75
Hahmlet (glyf, no vmtx) wght=700 LTR 100 80.08 78.84 40.24 2.0x 15.19
Hahmlet (glyf, no vmtx) wght=700 TTB 1 6.02 10.08 3.30 3.1x 1.45
Hahmlet (glyf, no vmtx) wght=700 TTB 8 10.06 40.80 6.18 6.6x 7.87
Hahmlet (glyf, no vmtx) wght=700 TTB 100 64.21 489.52 47.79 10.2x 227.45
Source Code Pro (CFF, no VORG) default LTR 1 1.10 1.16 1.15 1.0x 1.08
Source Code Pro (CFF, no VORG) default LTR 8 2.45 2.35 2.48 0.9x 2.23
Source Code Pro (CFF, no VORG) default LTR 100 10.88 12.54 11.25 1.1x 5.15
Source Code Pro (CFF, no VORG) default TTB 1 1.63 2.57 1.58 1.6x 1.78
Source Code Pro (CFF, no VORG) default TTB 8 2.18 4.64 2.21 2.1x 1.41
Source Code Pro (CFF, no VORG) default TTB 100 10.30 24.06 10.88 2.2x 5.22
Source Serif 4 VF (CFF2, no VORG) default LTR 1 10.54 11.03 4.18 2.6x 1.67
Source Serif 4 VF (CFF2, no VORG) default LTR 8 14.82 14.91 7.37 2.0x 2.41
Source Serif 4 VF (CFF2, no VORG) default LTR 100 75.33 75.98 46.63 1.6x 10.85
Source Serif 4 VF (CFF2, no VORG) default TTB 1 7.66 13.19 3.32 4.0x 1.47
Source Serif 4 VF (CFF2, no VORG) default TTB 8 11.03 33.60 5.35 6.3x 1.65
Source Serif 4 VF (CFF2, no VORG) default TTB 100 51.12 177.47 29.11 6.1x 4.78
Source Serif 4 VF (CFF2, no VORG) wght=700 LTR 1 10.16 10.57 4.27 2.5x 1.32
Source Serif 4 VF (CFF2, no VORG) wght=700 LTR 8 19.16 19.44 12.14 1.6x 2.21
Source Serif 4 VF (CFF2, no VORG) wght=700 LTR 100 123.61 124.93 95.62 1.3x 13.48
Source Serif 4 VF (CFF2, no VORG) wght=700 TTB 1 8.02 13.08 3.78 3.5x 1.51
Source Serif 4 VF (CFF2, no VORG) wght=700 TTB 8 14.90 40.08 9.06 4.4x 1.77
Source Serif 4 VF (CFF2, no VORG) wght=700 TTB 100 105.07 269.40 78.33 3.4x 6.33
Segoe UI default LTR 1 8.73 8.85 3.05 2.9x 1.47
Segoe UI default LTR 8 12.65 12.99 4.47 2.9x 2.32
Segoe UI default LTR 100 60.53 61.13 22.66 2.7x 5.63
Segoe UI default TTB 1 7.63 7.88 2.61 3.0x 2.12
Segoe UI default TTB 8 10.97 11.35 3.85 2.9x 1.86
Segoe UI default TTB 100 69.72 66.67 24.45 2.7x 4.80
Roboto default LTR 1 6.29 6.17 3.06 2.0x 1.29
Roboto default LTR 8 9.06 9.07 5.52 1.6x 2.16
Roboto default LTR 100 46.75 47.18 38.77 1.2x 9.67
Roboto default TTB 1 4.48 4.79 2.16 2.2x 1.63
Roboto default TTB 8 6.44 6.64 4.02 1.7x 1.58
Roboto default TTB 100 30.53 31.81 24.12 1.3x 3.71
Amiri (Arabic) default LTR 1 70.71 71.49 11.21 6.4x 1.63
Amiri (Arabic) default LTR 8 93.33 95.24 32.91 2.9x 8.14
Amiri (Arabic) default LTR 100 427.67 422.87 213.45 2.0x 78.07
Amiri (Arabic) default TTB 1 49.51 50.37 5.24 9.6x 1.60
Amiri (Arabic) default TTB 8 61.39 62.43 9.96 6.3x 4.09
Amiri (Arabic) default TTB 100 224.95 223.83 58.27 3.8x 18.13
Noto Sans Devanagari default LTR 1 405.70 402.46 12.67 31.8x 1.98
Noto Sans Devanagari default LTR 8 461.80 464.68 36.29 12.8x 5.38
Noto Sans Devanagari default LTR 100 793.07 782.31 209.92 3.7x 52.49
Noto Sans Devanagari default TTB 1 383.55 383.65 12.56 30.6x 2.59
Noto Sans Devanagari default TTB 8 505.92 507.65 48.30 10.5x 6.88
Noto Sans Devanagari default TTB 100 947.25 928.17 242.97 3.8x 55.33
Full table: a fresh Face and Font for every call (the first-call path, as oniq's backend shapes)
Font Axes Dir Chars 0.23.0 0.23.1 this branch 0.23.1 / branch
Noto Sans KR VF otf (CFF2, VORG) default LTR 1 65.39 66.59 15.56 4.3x
Noto Sans KR VF otf (CFF2, VORG) default LTR 8 78.74 80.17 30.16 2.7x
Noto Sans KR VF otf (CFF2, VORG) default LTR 100 260.54 246.10 191.16 1.3x
Noto Sans KR VF otf (CFF2, VORG) default TTB 1 73.46 75.11 16.89 4.4x
Noto Sans KR VF otf (CFF2, VORG) default TTB 8 98.91 97.07 35.82 2.7x
Noto Sans KR VF otf (CFF2, VORG) default TTB 100 253.30 254.32 193.84 1.3x
Noto Sans KR VF otf (CFF2, VORG) wght=700 LTR 1 68.88 73.46 21.41 3.4x
Noto Sans KR VF otf (CFF2, VORG) wght=700 LTR 8 97.72 90.03 37.61 2.4x
Noto Sans KR VF otf (CFF2, VORG) wght=700 LTR 100 315.11 301.08 240.44 1.3x
Noto Sans KR VF otf (CFF2, VORG) wght=700 TTB 1 68.23 69.46 17.13 4.1x
Noto Sans KR VF otf (CFF2, VORG) wght=700 TTB 8 83.12 83.70 31.93 2.6x
Noto Sans KR VF otf (CFF2, VORG) wght=700 TTB 100 299.66 308.56 245.80 1.3x
Noto Sans KR VF ttf (glyf, vmtx) default LTR 1 69.17 68.71 16.78 4.1x
Noto Sans KR VF ttf (glyf, vmtx) default LTR 8 91.81 91.11 33.12 2.8x
Noto Sans KR VF ttf (glyf, vmtx) default LTR 100 335.00 310.20 228.70 1.4x
Noto Sans KR VF ttf (glyf, vmtx) default TTB 1 81.77 77.09 17.90 4.3x
Noto Sans KR VF ttf (glyf, vmtx) default TTB 8 95.17 87.93 35.27 2.5x
Noto Sans KR VF ttf (glyf, vmtx) default TTB 100 318.00 350.84 278.85 1.3x
Noto Sans KR VF ttf (glyf, vmtx) wght=700 LTR 1 77.15 77.62 19.65 4.0x
Noto Sans KR VF ttf (glyf, vmtx) wght=700 LTR 8 94.29 97.11 34.49 2.8x
Noto Sans KR VF ttf (glyf, vmtx) wght=700 LTR 100 314.74 286.05 242.25 1.2x
Noto Sans KR VF ttf (glyf, vmtx) wght=700 TTB 1 74.20 69.86 18.05 3.9x
Noto Sans KR VF ttf (glyf, vmtx) wght=700 TTB 8 124.66 122.70 44.36 2.8x
Noto Sans KR VF ttf (glyf, vmtx) wght=700 TTB 100 351.56 331.28 268.06 1.2x
Hahmlet (glyf, no vmtx) wght=700 LTR 1 14.73 14.38 9.43 1.5x
Hahmlet (glyf, no vmtx) wght=700 LTR 8 19.71 19.58 13.58 1.4x
Hahmlet (glyf, no vmtx) wght=700 LTR 100 89.74 90.41 72.74 1.2x
Hahmlet (glyf, no vmtx) wght=700 TTB 1 7.43 14.87 14.52 1.0x
Hahmlet (glyf, no vmtx) wght=700 TTB 8 11.85 54.98 54.04 1.0x
Hahmlet (glyf, no vmtx) wght=700 TTB 100 72.89 593.44 581.02 1.0x
Source Code Pro (CFF, no VORG) default LTR 1 1.34 1.41 1.40 1.0x
Source Code Pro (CFF, no VORG) default LTR 8 1.80 1.90 1.86 1.0x
Source Code Pro (CFF, no VORG) default LTR 100 7.62 8.03 7.91 1.0x
Source Code Pro (CFF, no VORG) default TTB 1 1.28 2.13 2.07 1.0x
Source Code Pro (CFF, no VORG) default TTB 8 2.01 4.18 4.18 1.0x
Source Code Pro (CFF, no VORG) default TTB 100 9.76 22.75 22.45 1.0x
Source Serif 4 VF (CFF2, no VORG) default LTR 1 11.10 11.22 6.83 1.6x
Source Serif 4 VF (CFF2, no VORG) default LTR 8 15.09 15.53 11.01 1.4x
Source Serif 4 VF (CFF2, no VORG) default LTR 100 76.40 76.03 59.48 1.3x
Source Serif 4 VF (CFF2, no VORG) default TTB 1 8.43 14.41 10.65 1.4x
Source Serif 4 VF (CFF2, no VORG) default TTB 8 11.56 34.83 30.96 1.1x
Source Serif 4 VF (CFF2, no VORG) default TTB 100 56.47 195.13 175.13 1.1x
Source Serif 4 VF (CFF2, no VORG) wght=700 LTR 1 12.01 12.21 8.02 1.5x
Source Serif 4 VF (CFF2, no VORG) wght=700 LTR 8 22.05 22.14 16.86 1.3x
Source Serif 4 VF (CFF2, no VORG) wght=700 LTR 100 148.03 144.67 129.74 1.1x
Source Serif 4 VF (CFF2, no VORG) wght=700 TTB 1 8.51 13.81 10.66 1.3x
Source Serif 4 VF (CFF2, no VORG) wght=700 TTB 8 14.57 38.19 33.54 1.1x
Source Serif 4 VF (CFF2, no VORG) wght=700 TTB 100 99.75 255.35 224.86 1.1x
Segoe UI default LTR 1 10.10 10.38 8.23 1.3x
Segoe UI default LTR 8 15.51 15.34 12.59 1.2x
Segoe UI default LTR 100 65.00 66.67 47.26 1.4x
Segoe UI default TTB 1 8.01 8.21 6.65 1.2x
Segoe UI default TTB 8 12.85 13.08 9.69 1.3x
Segoe UI default TTB 100 76.16 85.09 54.26 1.6x
Roboto default LTR 1 9.17 9.74 7.99 1.2x
Roboto default LTR 8 10.85 11.25 10.45 1.1x
Roboto default LTR 100 54.38 53.44 56.47 0.9x
Roboto default TTB 1 5.15 5.41 4.42 1.2x
Roboto default TTB 8 7.25 7.23 6.33 1.1x
Roboto default TTB 100 38.58 37.88 38.20 1.0x
Amiri (Arabic) default LTR 1 68.45 69.26 18.82 3.7x
Amiri (Arabic) default LTR 8 99.72 100.09 58.18 1.7x
Amiri (Arabic) default LTR 100 458.66 453.62 420.58 1.1x
Amiri (Arabic) default TTB 1 52.09 54.24 12.95 4.2x
Amiri (Arabic) default TTB 8 66.98 68.58 27.69 2.5x
Amiri (Arabic) default TTB 100 238.42 237.47 179.64 1.3x
Noto Sans Devanagari default LTR 1 415.66 421.73 44.39 9.5x
Noto Sans Devanagari default LTR 8 519.32 526.83 92.73 5.7x
Noto Sans Devanagari default LTR 100 876.78 864.72 511.04 1.7x
Noto Sans Devanagari default TTB 1 383.29 379.45 41.17 9.2x
Noto Sans Devanagari default TTB 8 467.64 471.61 82.88 5.7x
Noto Sans Devanagari default TTB 100 893.64 894.01 501.24 1.8x

Verification

  • Byte-identical output, glyph ids, clusters, advances and offsets, this branch against main (fce2d02), with HarfBuzz 14.5.0 alongside:
    • shape_cmp.py from 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).
    • The same script over 47 more fonts (every complex-script font in 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.
    • Both corpora run twice: with one Font per font and setting (warm caches) and with a fresh Font for every text (the first-call path): 0 changed either way.
    • 11,760 runs with 14 feature lists (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.
    • The audit's 554-case consumer differential (Open Sans, Noto Sans, Sora, Chakra Petch, JetBrains Mono, Source Code Pro, Noto Sans Hebrew, Noto Sans KR VF, Hahmlet, the KR CFF2 subset, SourceSans3 VF, Nabla, Segoe UI Emoji): 554 of 554 match HarfBuzz, as on main.
    • B2 repros (Calibri office with liga=0,liga=1, AVAT with kern=0,kern=1 and reversed): all four match HarfBuzz.
  • Tests added: tests/font_caches.rs (9 fixtures, both directions, three feature lists, one Font shaped twice over against a fresh Font per call; 8 threads sharing one Font in different orders; with_coords and with_size cache 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 for OnceBox, digests, accelerators and both budgets, feature maps against the walk, stage plans, glyph caches, with_variations.
  • Gate: 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 after CARGO_RESOLVER_INCOMPATIBLE_RUST_VERSIONS=fallback cargo +stable generate-lockfile: cargo +1.81 check --workspace --lib --no-default-features and cargo +1.81 check --workspace --lib --bins --all-features. All pass.
  • Fuzzing: shape and face, 10 minutes each in Docker, no findings (details above).

Not done

  • Per-glyph cost of long runs is still 2x to 5x HarfBuzz (100-character rows: normalization, Hangul composition, PairPos walks). This PR targets the per-call setup; those passes are unchanged.
  • No caller-owned plan object (hb_shape_plan style). The caches live in Font instead, so existing shape(&font, ...) callers get them by keeping their Font. A caller that builds a Font per run gets only the first-call improvements: oniq's sigilbuzz backend parses the Face and builds the Font in shape_run, so it should keep one Font per font and size to get the rest.
  • A one-shot 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's hb_font_set_variations still normalizes by hand and sets only the first axis with a repeated tag; it could call Font::with_variations in a follow-up.
  • oneiric's per-run Cff2 workaround can move to Face::glyph_outlines; the UAX 50 vertical-orientation helper from N4 is not part of this PR.
  • docs/PERFORMANCE.md still holds the 0.6.0 Criterion numbers.

CHANGELOG lines (0.24.0)

Added:

Changed:

Fixed:

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.
@albedosehen albedosehen added this to the 0.24.0 milestone Oct 4, 2026
@albedosehen
albedosehen marked this pull request as ready for review October 4, 2026 20:45
@albedosehen
albedosehen merged commit 8b04190 into main Oct 4, 2026
8 checks passed
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.

Cut the per-call shaping cost and the vertical-run slowdown

1 participant