Skip to content

Bound the shaping caches' work and memory on hostile fonts - #301

Merged
albedosehen merged 10 commits into
mainfrom
fix/cache-bounds
Oct 5, 2026
Merged

albedosehen merged 10 commits into
mainfrom
fix/cache-bounds

Conversation

@albedosehen

@albedosehen albedosehen commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Closes #297

Bounds the work and memory of the per-Font shaping caches that #295 added, on the hostile tables the #295 review built and five more, and finds the per-glyph work that kept long vertical runs slower than 0.23.0. Shaping output is byte-identical to main everywhere (see Verification).

What changed

1. Subtable cache rank (the blocker), src/shape/lazy.rs

LazySubtables::has_cache counted the subtables that parse before an index by scanning from 0, at every class-based context subtable past the eighth, for every glyph: O(N²) per glyph in the lookup's subtable count. It now keeps its count across calls, advancing from where the last call stopped and stopping at the eighth subtable that parses, which fixes the cutoff for the rest of the lookup application. Each subtable is counted at most once per application, so a visit costs O(1) amortized.

A unit test counts subtable reads: four glyphs over 6,000 class-based subtables now take at most 30,000 reads; the old scan took 48,039,904 (checked by reverting the fix). Another test checks the rank against a fresh scan in three visiting orders.

The walk also got cheaper per subtable visit, which the 32,000-subtable fonts below need to stay near 0.23.1:

  • the accelerator check and the slot read are one call (get_admitted), and a slot past the last subtable reads as one that does not parse, so a visit does one bounds check;
  • a subtable that fails to parse takes no room in the parsed list;
  • the parsed list reserves room for every subtable once eight have parsed, instead of copying at each doubling (as parsing them all up front did);
  • parsing goes through a Subtable trait instead of function pointers;
  • a lookup without per-subtable digests (item 2) skips the per-subtable accelerator check.

2. Accelerator memory, src/tables/layout/accel.rs

  • Each subtable of a lookup is charged one entry of the table's budget (2 x table length + 64 Ki), for the digest it may keep, before any coverage is read. A lookup the budget cannot pay for keeps no digest and admits every glyph (only less skipping; output unchanged).
  • Lookup indices with the same LookupList offset share one accelerator. Lists that compilers write name each lookup once at increasing offsets, which is checked in one pass without sorting or allocating; only other lists get a u16 map per index.
  • A lookup whose subtables all have the lookup's own digest (one subtable, or many sharing a coverage) keeps only that digest: every walk checks the lookup's digest before trying a subtable.
  • A font's first call reads at most 1,024 coverages per lookup application (DirectAccel); subtables past them admit every glyph and are parsed when reached, as every subtable was before Cut per-call shaping cost with per-Font accelerators, plans and metric caches #295. Real lookups stay far below (Noto Sans KR's ccmp has 268).

What a Font keeps per table is now one slot pointer per lookup, a 40-byte header per lookup applied, and 24 bytes per kept digest, for at most 2 x table length + 64 Ki digests. tests/hostile_layout_caches.rs builds hostile GSUB and GPOS tables on Open Sans and checks the bytes a used Font keeps (with a per-thread counting allocator) and that it shapes as a fresh Font; on main its two memory tests fail (3.1 MB kept for a 4.3 KB GSUB; 19.2 MB kept against a bound of 2.0 MB).

3. Vertical long runs, src/shape/position.rs, src/font/cache.rs

Profiled with in-process laps around the pipeline stages (a copy of the crate with Instant laps, one sigilbuzz per binary), Hahmlet[wght] at wght 700, 20 texts of 100 Hangul syllables, TTB, warm Font:

Stage Before After
whole call 54.7 us 22.6 us
vertical origins (subtract_vertical_origins) 28.6 us (53%) 0.7 us (3%)
of which h_advance, per glyph 281 ns 48 ns
of which v_origin, per glyph 106 ns 48 ns

(The per-glyph laps are a separate build; each includes about 40 ns of lap overhead.) A vertical run moves every glyph by half its horizontal advance, and at non-default coordinates that advance comes from HVAR, whose delta walks the variation store's regions. That walk ran for every glyph of every call; the vertical origins were cached, but each glyph's first lookup in a call also inserted it into the call's own BTreeMap.

  • The varied advances, from HVAR and VVAR as from phantom points, share the font's two existing advance caches (a font has HVAR or not, so one instance only ever stores one kind). Still three caches of at most 4096 four-byte entries, Send + Sync, single-word atomic entries.
  • The vertical origins and phantom advances read the font's cache before the call's maps, so a warm call adds no map entries.
  • Horizontal runs of HVAR fonts gain too (Hahmlet LTR 100: 39.8 to 22.3 us; Source Serif 4 VF wght 700 LTR 100: 94.1 to 62.2 us).
  • Nothing in src/face/outlines.rs needed changing.

4. Feature maps past the cap, src/ot/layout_select.rs

Past 16 (script, language, FeatureVariations record) keys, FeatureMaps::get returns None and the query walks the language system, as without a cache, instead of resolving the whole language system into a map per query. The first 16 keys a font is shaped with stay kept; a map and the walk answer alike, so which keys those are never shows in the output.

5 to 7

  • docs/STABILITY.md: Font::coords is no longer a const fn, next to the lifetime note.
  • The crate docs, README and a new "Supported targets" section of docs/STABILITY.md: the core crate needs pointer-width atomics with compare-and-swap (cfg(target_has_atomic = "ptr")). Checked: cargo build -p sigilbuzz --target thumbv6m-none-eabi --no-default-features fails on this branch, and on 0.23.1 (fce2d02) through alloc::sync in language/mod.rs and owned.rs.
  • fuzz/src/lib.rs: shape_samples (the shape and shape_text targets) shapes every text with the reused Font and with a fresh Font, bidi runs included, and asserts the glyphs are equal.

Before and after

Measurement

One sigilbuzz per binary, as a user links it (crates.io 0.23.0, 0.23.1 at fce2d02, main at 8b04190, this branch), lto = "thin", codegen-units = 1. Each run reports the median thread-cycle time of 11 batches of about 10 ms; the versions of a row run in alternation, and a table cell is the median over 5 runs (3 for first-call rows). Windows 11, i9-11900H, shared with other jobs at about 25% background load, so differences under about 4% are noise; rows that came out more than 3% slower than main were re-measured with 7 to 15 runs and were within noise. Times are microseconds per call unless marked.

The #295 tables came from one binary that linked 0.23.0, 0.23.1 and the branch together. In such a binary main's Hahmlet TTB 100 run measured 40 us; in single-version binaries it measures 43 to 50 us with this harness and 88 to 107 us with the review's harness (wall clock over every call, the first ones included). This branch measures 20 to 31 us in all three, since warm calls no longer walk HVAR per glyph. The tables below are single-version only.

Hostile layout tables (items 1 and 2)

Open Sans with its GSUB or GPOS replaced. The first four are the review's (make_hostile.py, make_hostile2.py); the others are new (make_hostile3.py): format 1 contexts, GPOS chained context positioning in formats 2 and 3, 8,000 lookups at distinct offsets that overlap in a 96 KB GSUB (index sharing cannot help there, only the table budget), and 16 distinct lookups of 2,000 subtables whose digests differ (all kept, as they should be). Output is identical on 0.23.1, main and this branch for every font.

Time per call, warm Font (first call in parentheses). For the millisecond rows, 0.23.1 and the branch are the median of 9 alternating runs, and main is the range of single calls over several runs, which vary by 30% or more under the machine's load. The rows in seconds are the range of four single calls per version in one session.

Table Text 0.23.1 main this branch branch / 0.23.1
GSUB, 32,000 offsets to one ChainContextFormat2 (64 KB) aaaa 5.19 ms (4.93) 5,900 to 12,600 ms 5.49 ms (5.47) 1.06 (1.11)
GSUB, 8,000 of them (16 KB) aaaa 1.31 ms (1.36) 224 to 386 ms 1.37 ms (1.44) 1.04 (1.06)
GSUB, 32,000 offsets to one ChainContextFormat3 (64 KB) a 6.80 ms (6.56) 8.9 to 17.7 ms 6.10 ms (6.49) 0.90 (0.99)
GSUB, 32,000 offsets to one ChainContextFormat1 (64 KB) aaaa 3.99 ms (4.02) 6.6 to 13.0 ms 4.32 ms (4.37) 1.08 (1.09)
GPOS, 32,000 offsets to one ChainContextPos format 2 (64 KB) aaaa 4.85 ms (5.06) 7,200 to 10,800 ms 5.30 ms (5.61) 1.09 (1.11)
GSUB, 500 indices of one lookup of 32,000 format 3 subtables (66 KB) a 5.6 to 7.0 s 5.3 to 8.6 s 4.5 to 5.9 s
GPOS, the same with ChainContextPos (66 KB) a 6.1 to 6.7 s 5.4 to 8.3 s 4.1 to 4.7 s
GSUB, 8,000 overlapping lookups of 8,000 subtables (96 KB) a 1.6 to 2.0 s 4.4 to 6.7 s 2.2 to 2.5 s

Heap, measured with a counting allocator over three calls on one Font: the peak during the calls, and what the Font keeps after them.

Table 0.23.1 peak / kept main peak / kept this branch peak / kept
GSUB, 32,000 x ChainContextFormat2 5.4 MB / 0 6.5 MB / 752 KB 5.5 MB / 2 KB
GPOS, 32,000 x format 2 5.6 MB / 0 6.8 MB / 753 KB 5.8 MB / 3 KB
GSUB, 500 indices x 32,000 format 3 7.3 MB / 0 374 MB / 366 MB 7.5 MB / 15 KB
GPOS, 500 indices x 32,000 format 3 7.6 MB / 0 374 MB / 366 MB 7.7 MB / 15 KB
GSUB, 8,000 overlapping lookups 1.4 MB / 0 1,467 MB / 1,465 MB 7.8 MB / 6.3 MB
GSUB, 16 lookups x 2,000 distinct digests 0.3 MB / 0 0.7 MB / 0.7 MB 0.7 MB / 0.7 MB

The review's 65,535-index variant would have kept about 50 GB on main; on this branch its indices share one accelerator. The overlapping-lookups table is the case only the budget bounds: 6.3 MB kept for a 96 KB table, which is what the budget allows (24 bytes per digest for 2 x 96,058 + 64 Ki digests is 5.9 MiB, plus the 8,000 lookups' slots and headers).

Vertical runs against 0.23.0 (item 3)

Warm Font, TTB, 20 texts per row. Hahmlet has glyf, gvar and HVAR but no vmtx; Noto Sans KR VF ttf has vmtx and HVAR; the otf has VORG, VVAR and HVAR; Source Serif 4 VF is CFF2 with HVAR and no VORG; Source Code Pro is static CFF without VORG.

Font Axes Chars 0.23.0 main this branch branch / 0.23.0
Noto Sans KR VF otf (CFF2, VORG) default 8 66.63 6.77 6.38 0.10
Noto Sans KR VF otf (CFF2, VORG) default 100 222.00 42.74 36.70 0.17
Noto Sans KR VF otf (CFF2, VORG) wght=700 8 71.24 9.40 7.47 0.10
Noto Sans KR VF otf (CFF2, VORG) wght=700 100 266.18 96.58 54.90 0.21
Noto Sans KR VF ttf (glyf, vmtx) default 8 66.09 6.76 6.32 0.10
Noto Sans KR VF ttf (glyf, vmtx) default 100 231.34 45.80 37.37 0.16
Noto Sans KR VF ttf (glyf, vmtx) wght=700 8 85.79 11.53 7.68 0.09
Noto Sans KR VF ttf (glyf, vmtx) wght=700 100 315.74 78.10 50.65 0.16
Hahmlet (glyf, no vmtx) wght=700 8 10.12 6.32 4.39 0.43
Hahmlet (glyf, no vmtx) wght=700 100 66.91 43.64 20.13 0.30
Source Code Pro (CFF, no VORG) default 8 1.87 1.97 1.88 1.01
Source Code Pro (CFF, no VORG) default 100 9.54 10.70 8.57 0.90
Source Serif 4 VF (CFF2, no VORG) default 8 10.58 4.83 4.68 0.44
Source Serif 4 VF (CFF2, no VORG) default 100 49.00 26.66 22.83 0.47
Source Serif 4 VF (CFF2, no VORG) wght=700 8 13.03 7.54 4.71 0.36
Source Serif 4 VF (CFF2, no VORG) wght=700 100 77.40 59.73 23.66 0.31

With the review's harness (wall clock, one font per spec built by root.clone().with_coords, first calls included, median of 7 alternating processes), Hahmlet wght 700 TTB: 100 characters, 0.23.0 80.9 us, main 103.3 us, this branch 31.1 us; 8 characters, 14.0, 7.8 and 5.8 us. The review measured 64.8 and 88.7 us (100) and 11.28 and 6.71 us (8) for 0.23.0 and main.

A font's first call is still slower than 0.23.0 for fonts without VORG (Hahmlet TTB 100: 69.2 us on 0.23.0, 503.7 on main, 510.9 here; Source Serif 4 VF: 50.0, 153.4 and 160.6; Source Code Pro: 9.7, 23.4 and 24.4), because 0.24.0 takes HarfBuzz's vertical origins from glyph extents, which a first call computes per new glyph. This PR does not change that.

40 languages on one Font (item 4)

The review's jobs: "office AVAT" in 12 or 40 languages, one Font, per call.

Font Languages 0.23.0 main this branch branch / main
Calibri 12 17.06 8.56 8.44 0.99
Calibri 40 16.60 16.43 9.05 0.55
Segoe UI 12 15.27 5.52 5.44 0.99
Segoe UI 40 13.94 13.49 6.06 0.45

(A quieter earlier run gave the same ratios: Calibri 40 languages 13.84 to 7.98 us, Segoe UI 10.43 to 5.13 us.)

The #295 rows, main against this branch

No row is slower than main beyond noise. The tables were measured at 37108e3; 375ee8b changes only first calls of lookups with more than 1,024 subtables, and the first-call rows re-measured on it were within noise of main.

Repeated calls on one Font
Font Axes Dir Chars 0.23.0 main this branch branch / main
Noto Sans KR VF otf (CFF2, VORG) default LTR 1 56.51 3.33 3.38 1.02
Noto Sans KR VF otf (CFF2, VORG) default LTR 8 70.60 7.81 7.73 0.99
Noto Sans KR VF otf (CFF2, VORG) default LTR 100 228.42 46.39 42.49 0.92
Noto Sans KR VF otf (CFF2, VORG) default TTB 1 59.31 3.30 3.42 1.04
Noto Sans KR VF otf (CFF2, VORG) default TTB 8 66.63 6.77 6.38 0.94
Noto Sans KR VF otf (CFF2, VORG) default TTB 100 222.00 42.74 36.70 0.86
Noto Sans KR VF otf (CFF2, VORG) wght=700 LTR 1 58.89 3.71 3.47 0.94
Noto Sans KR VF otf (CFF2, VORG) wght=700 LTR 8 71.06 12.52 8.86 0.71
Noto Sans KR VF otf (CFF2, VORG) wght=700 LTR 100 246.20 65.59 47.66 0.73
Noto Sans KR VF otf (CFF2, VORG) wght=700 TTB 1 57.70 3.92 3.61 0.92
Noto Sans KR VF otf (CFF2, VORG) wght=700 TTB 8 71.24 9.40 7.47 0.79
Noto Sans KR VF otf (CFF2, VORG) wght=700 TTB 100 266.18 96.58 54.90 0.57
Noto Sans KR VF ttf (glyf, vmtx) default LTR 1 57.47 3.23 3.21 0.99
Noto Sans KR VF ttf (glyf, vmtx) default LTR 8 92.63 8.47 8.12 0.96
Noto Sans KR VF ttf (glyf, vmtx) default LTR 100 220.00 47.61 44.09 0.93
Noto Sans KR VF ttf (glyf, vmtx) default TTB 1 58.17 3.31 3.30 1.00
Noto Sans KR VF ttf (glyf, vmtx) default TTB 8 66.09 6.76 6.32 0.94
Noto Sans KR VF ttf (glyf, vmtx) default TTB 100 231.34 45.80 37.37 0.82
Noto Sans KR VF ttf (glyf, vmtx) wght=700 LTR 1 57.77 3.66 3.46 0.94
Noto Sans KR VF ttf (glyf, vmtx) wght=700 LTR 8 66.77 12.15 7.77 0.64
Noto Sans KR VF ttf (glyf, vmtx) wght=700 LTR 100 253.46 68.04 49.81 0.73
Noto Sans KR VF ttf (glyf, vmtx) wght=700 TTB 1 56.65 3.87 3.67 0.95
Noto Sans KR VF ttf (glyf, vmtx) wght=700 TTB 8 85.79 11.53 7.68 0.67
Noto Sans KR VF ttf (glyf, vmtx) wght=700 TTB 100 315.74 78.10 50.65 0.65
Hahmlet (glyf, no vmtx) wght=700 LTR 1 13.19 3.23 2.97 0.92
Hahmlet (glyf, no vmtx) wght=700 LTR 8 16.96 6.13 4.59 0.75
Hahmlet (glyf, no vmtx) wght=700 LTR 100 90.39 39.80 22.29 0.56
Hahmlet (glyf, no vmtx) wght=700 TTB 1 7.07 3.26 2.99 0.92
Hahmlet (glyf, no vmtx) wght=700 TTB 8 10.12 6.32 4.39 0.69
Hahmlet (glyf, no vmtx) wght=700 TTB 100 66.91 43.64 20.13 0.46
Source Code Pro (CFF, no VORG) default LTR 1 1.02 1.10 1.11 1.01
Source Code Pro (CFF, no VORG) default LTR 8 1.61 1.74 1.71 0.99
Source Code Pro (CFF, no VORG) default LTR 100 7.05 8.05 8.06 1.00
Source Code Pro (CFF, no VORG) default TTB 1 1.12 1.79 1.76 0.99
Source Code Pro (CFF, no VORG) default TTB 8 1.87 1.97 1.88 0.96
Source Code Pro (CFF, no VORG) default TTB 100 9.54 10.70 8.57 0.80
Source Serif 4 VF (CFF2, no VORG) default LTR 1 9.76 3.64 3.64 1.00
Source Serif 4 VF (CFF2, no VORG) default LTR 8 14.24 7.36 6.90 0.94
Source Serif 4 VF (CFF2, no VORG) default LTR 100 72.64 43.54 43.68 1.00
Source Serif 4 VF (CFF2, no VORG) default TTB 1 7.14 2.82 2.81 0.99
Source Serif 4 VF (CFF2, no VORG) default TTB 8 10.58 4.83 4.68 0.97
Source Serif 4 VF (CFF2, no VORG) default TTB 100 49.00 26.66 22.83 0.86
Source Serif 4 VF (CFF2, no VORG) wght=700 LTR 1 9.84 4.26 3.95 0.93
Source Serif 4 VF (CFF2, no VORG) wght=700 LTR 8 18.96 10.67 8.15 0.76
Source Serif 4 VF (CFF2, no VORG) wght=700 LTR 100 118.53 94.14 62.23 0.66
Source Serif 4 VF (CFF2, no VORG) wght=700 TTB 1 7.64 3.53 3.11 0.88
Source Serif 4 VF (CFF2, no VORG) wght=700 TTB 8 13.03 7.54 4.71 0.62
Source Serif 4 VF (CFF2, no VORG) wght=700 TTB 100 77.40 59.73 23.66 0.40
Segoe UI default LTR 1 7.08 2.32 2.29 0.99
Segoe UI default LTR 8 10.12 3.73 3.78 1.01
Segoe UI default LTR 100 53.78 21.19 21.86 1.03
Segoe UI default TTB 1 6.33 2.16 2.15 1.00
Segoe UI default TTB 8 9.75 3.38 3.24 0.96
Segoe UI default TTB 100 50.46 19.45 16.35 0.84
Roboto default LTR 1 5.41 2.70 2.68 0.99
Roboto default LTR 8 8.17 5.48 5.72 1.04
Roboto default LTR 100 42.36 35.51 36.23 1.02
Roboto default TTB 1 4.19 2.24 2.14 0.96
Roboto default TTB 8 6.08 4.08 4.01 0.98
Roboto default TTB 100 28.50 23.05 20.18 0.88
Amiri (Arabic) default LTR 1 58.51 10.00 9.63 0.96
Amiri (Arabic) default LTR 8 90.63 30.78 30.44 0.99
Amiri (Arabic) default LTR 100 413.22 223.38 208.64 0.93
Amiri (Arabic) default TTB 1 46.15 4.97 4.17 0.84
Amiri (Arabic) default TTB 8 56.94 8.36 8.53 1.02
Amiri (Arabic) default TTB 100 216.28 52.87 49.65 0.94
Noto Sans Devanagari default LTR 1 370.87 9.92 10.12 1.02
Noto Sans Devanagari default LTR 8 438.20 31.00 29.12 0.94
Noto Sans Devanagari default LTR 100 806.56 201.52 204.08 1.01
Noto Sans Devanagari default TTB 1 360.25 10.04 9.98 0.99
Noto Sans Devanagari default TTB 8 422.78 39.47 37.47 0.95
Noto Sans Devanagari default TTB 100 799.29 208.53 206.75 0.99
A fresh Face and Font for every call
Font Axes Dir Chars 0.23.0 main this branch branch / main
Noto Sans KR VF otf (CFF2, VORG) default LTR 1 56.45 13.12 12.74 0.97
Noto Sans KR VF otf (CFF2, VORG) default LTR 8 63.65 24.56 25.76 1.05
Noto Sans KR VF otf (CFF2, VORG) default LTR 100 218.88 167.85 163.43 0.97
Noto Sans KR VF otf (CFF2, VORG) default TTB 1 56.43 12.83 12.54 0.98
Noto Sans KR VF otf (CFF2, VORG) default TTB 8 65.47 22.72 23.08 1.02
Noto Sans KR VF otf (CFF2, VORG) default TTB 100 216.12 166.50 170.51 1.02
Noto Sans KR VF otf (CFF2, VORG) wght=700 LTR 1 59.94 13.89 13.55 0.98
Noto Sans KR VF otf (CFF2, VORG) wght=700 LTR 8 67.88 25.90 26.94 1.04
Noto Sans KR VF otf (CFF2, VORG) wght=700 LTR 100 244.63 182.28 175.47 0.96
Noto Sans KR VF otf (CFF2, VORG) wght=700 TTB 1 53.85 13.47 13.17 0.98
Noto Sans KR VF otf (CFF2, VORG) wght=700 TTB 8 68.31 25.59 25.68 1.00
Noto Sans KR VF otf (CFF2, VORG) wght=700 TTB 100 240.42 186.52 190.12 1.02
Noto Sans KR VF ttf (glyf, vmtx) default LTR 1 56.16 13.30 13.46 1.01
Noto Sans KR VF ttf (glyf, vmtx) default LTR 8 68.36 24.11 24.07 1.00
Noto Sans KR VF ttf (glyf, vmtx) default LTR 100 211.36 157.46 159.16 1.01
Noto Sans KR VF ttf (glyf, vmtx) default TTB 1 54.41 13.04 12.87 0.99
Noto Sans KR VF ttf (glyf, vmtx) default TTB 8 65.31 23.76 23.67 1.00
Noto Sans KR VF ttf (glyf, vmtx) default TTB 100 212.30 169.55 172.75 1.02
Noto Sans KR VF ttf (glyf, vmtx) wght=700 LTR 1 54.40 13.57 13.41 0.99
Noto Sans KR VF ttf (glyf, vmtx) wght=700 LTR 8 68.45 26.04 26.02 1.00
Noto Sans KR VF ttf (glyf, vmtx) wght=700 LTR 100 244.49 178.31 184.43 1.03
Noto Sans KR VF ttf (glyf, vmtx) wght=700 TTB 1 54.41 13.77 13.89 1.01
Noto Sans KR VF ttf (glyf, vmtx) wght=700 TTB 8 81.58 28.41 30.00 1.06
Noto Sans KR VF ttf (glyf, vmtx) wght=700 TTB 100 347.54 276.26 254.19 0.92
Hahmlet (glyf, no vmtx) wght=700 LTR 1 13.33 9.51 9.58 1.01
Hahmlet (glyf, no vmtx) wght=700 LTR 8 17.70 11.94 11.82 0.99
Hahmlet (glyf, no vmtx) wght=700 LTR 100 91.97 71.85 74.23 1.03
Hahmlet (glyf, no vmtx) wght=700 TTB 1 7.09 13.13 13.33 1.01
Hahmlet (glyf, no vmtx) wght=700 TTB 8 10.53 40.86 41.80 1.02
Hahmlet (glyf, no vmtx) wght=700 TTB 100 69.16 503.70 510.91 1.01
Source Code Pro (CFF, no VORG) default LTR 1 1.12 1.18 1.20 1.02
Source Code Pro (CFF, no VORG) default LTR 8 1.70 1.76 1.83 1.04
Source Code Pro (CFF, no VORG) default LTR 100 7.21 8.35 8.01 0.96
Source Code Pro (CFF, no VORG) default TTB 1 1.23 1.95 1.97 1.01
Source Code Pro (CFF, no VORG) default TTB 8 1.94 4.12 4.35 1.06
Source Code Pro (CFF, no VORG) default TTB 100 9.67 23.35 24.41 1.05
Source Serif 4 VF (CFF2, no VORG) default LTR 1 9.07 6.35 6.47 1.02
Source Serif 4 VF (CFF2, no VORG) default LTR 8 13.92 12.18 11.60 0.95
Source Serif 4 VF (CFF2, no VORG) default LTR 100 69.69 54.31 54.30 1.00
Source Serif 4 VF (CFF2, no VORG) default TTB 1 7.69 9.66 9.61 1.00
Source Serif 4 VF (CFF2, no VORG) default TTB 8 11.31 27.99 27.84 0.99
Source Serif 4 VF (CFF2, no VORG) default TTB 100 50.00 153.35 160.56 1.05
Source Serif 4 VF (CFF2, no VORG) wght=700 LTR 1 9.56 6.95 7.03 1.01
Source Serif 4 VF (CFF2, no VORG) wght=700 LTR 8 19.12 16.45 16.12 0.98
Source Serif 4 VF (CFF2, no VORG) wght=700 LTR 100 124.17 103.78 104.79 1.01
Source Serif 4 VF (CFF2, no VORG) wght=700 TTB 1 8.78 10.41 10.55 1.01
Source Serif 4 VF (CFF2, no VORG) wght=700 TTB 8 14.39 36.14 34.31 0.95
Source Serif 4 VF (CFF2, no VORG) wght=700 TTB 100 81.98 190.80 189.34 0.99
Segoe UI default LTR 1 7.08 5.82 6.35 1.09
Segoe UI default LTR 8 10.76 9.18 9.35 1.02
Segoe UI default LTR 100 58.14 40.55 44.59 1.10
Segoe UI default TTB 1 6.58 5.61 5.55 0.99
Segoe UI default TTB 8 9.95 7.92 8.15 1.03
Segoe UI default TTB 100 52.04 37.22 36.11 0.97
Roboto default LTR 1 5.84 5.45 5.62 1.03
Roboto default LTR 8 8.24 8.95 8.89 0.99
Roboto default LTR 100 42.39 47.43 47.02 0.99
Roboto default TTB 1 4.58 4.10 3.90 0.95
Roboto default TTB 8 7.23 6.04 6.03 1.00
Roboto default TTB 100 29.56 31.87 31.59 0.99
Amiri (Arabic) default LTR 1 63.74 18.19 17.53 0.96
Amiri (Arabic) default LTR 8 88.06 50.14 50.56 1.01
Amiri (Arabic) default LTR 100 455.94 433.69 388.00 0.89
Amiri (Arabic) default TTB 1 46.51 11.22 11.32 1.01
Amiri (Arabic) default TTB 8 58.75 23.77 22.69 0.95
Amiri (Arabic) default TTB 100 213.42 160.61 164.33 1.02
Noto Sans Devanagari default LTR 1 370.78 44.63 45.64 1.02
Noto Sans Devanagari default LTR 8 464.15 70.80 70.46 1.00
Noto Sans Devanagari default LTR 100 847.40 496.54 499.24 1.01
Noto Sans Devanagari default TTB 1 380.26 39.91 40.97 1.03
Noto Sans Devanagari default TTB 8 421.72 74.37 68.84 0.93
Noto Sans Devanagari default TTB 100 776.64 495.54 498.88 1.01

Verification

  • Byte-identical output against main (8b04190), glyph ids, clusters, advances, offsets and flags, on the final code:
    • The Cut per-call shaping cost with per-Font accelerators, plans and metric caches #295 corpus (shape_cmp.py from the Close variable-font metric parity gaps with HarfBuzz 14.5.0 #283 review as jobs files: 22 fonts, every axis setting, both directions), seeds 99 and 7: 190,070 jobs each. Each job shaped by main with a fresh Font, and by this branch both with a fresh Font and with one Font per setting rebound from one root and shared by 4 threads in shuffled orders: 0 differ, 0 differences between threads.
    • 60,000 random jobs over 24 fonts with feature lists, forced scripts, 46 languages and axis settings (the review's gen_jobs.py, seed 1717), the same three ways: 0 differ.
    • The audit's 554-case consumer differential: 554 of 554 match HarfBuzz, as on main; every case is also shaped a second time on the same Font, and the two agree.
    • Every hostile font above shapes to the same glyphs on 0.23.1, main and this branch.
  • Tests added: lazy.rs (the cache rank against a fresh scan in three orders; reads per visit), accel.rs (digests charged to the budget, shared indices, lookups that keep only their own digest, the first-call cap), layout_select.rs (keys past the slots walk and are never resolved), position.rs (varied HVAR/VVAR advances kept and read back per direction), tests/hostile_layout_caches.rs (bytes a used Font keeps on three hostile tables, output against a fresh Font).
  • 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 (201 test binaries); cargo build --no-default-features; cargo build -p sigilbuzz --target wasm32-unknown-unknown --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 in Docker (rustlang/rust:nightly, cargo-fuzz 0.13.2, -O -a, -max_len=65536 -rss_limit_mb=2048, seeds from tests/fixtures and tests/fonts, no --privileged), 10 minutes per run:
    • face: 432,408 runs, cov 6,705, peak RSS 670 MB, no findings.
    • shape on the final code with -timeout=10: 81,121 runs, cov 15,832, peak RSS 636 MB, no findings; on 37108e3: 99,602 runs, none.
    • An earlier shape run (at the vertical fix, before the walk trimming) stopped after 143,129 runs on a timeout: a fuzzed font whose GSUB turns a 30-character Devanagari sample into 3,079 glyphs. That input is slow on main too: one fuzz-build run of it takes 23.5 to 24.7 s on main and 32.0 to 35.3 s on this branch, which now shapes every text twice (once per Font); natively 0.72 to 0.81 s on main against 0.45 to 0.57 s here for both shapings. A run with -timeout=60 on the same code: 46,012 runs, no findings.

Not done

  • The lookups of 32,000 format 1 and format 2 context subtables are 4% to 11% slower than 0.23.1, warm or first call (format 3: 0.90 to 0.99), not at or below it. What is left is lazy parsing's per-subtable bookkeeping (a slot read and a branch per visit) that Cut per-call shaping cost with per-Font accelerators, plans and metric caches #295's real-font speedups rely on. HarfBuzz takes 0.6 ms on the format 2 font.
  • The overlapping-lookups font takes 2.2 to 2.5 s per call against 1.6 to 2.0 s on 0.23.1 (main: 4.4 to 6.7 s). Past the table budget its lookups admit every glyph and pay that bookkeeping on 8,000 subtables each.
  • A font's first top-to-bottom call stays slower than 0.23.0 for fonts without VORG (table above).
  • The slow Devanagari fuzz input above (3,079 glyphs from 30 characters, 0.8 s natively on main) is not investigated; it is not specific to the caches.
  • Source Code Pro (static CFF) shapes 100 Latin characters left to right in about 8.0 us on main and on this branch against 7.0 us on 0.23.0 (warm; 8.0 to 8.4 us against 7.2 us for a first call), in single-version builds; Cut per-call shaping cost with per-Font accelerators, plans and metric caches #295's combined-binary table showed it within noise. Not a cache item, so not addressed here.
  • docs/PERFORMANCE.md still holds the 0.6.0 Criterion numbers.

CHANGELOG lines (0.24.0)

Replace #295's vertical-speed line with:

Add to Changed:

Amend #295's Font::coords line to:

Review fixes (coordinator)

The independent review of this PR found no blockers. Two items fixed by the coordinator in 61cc790:

  • src/shape/lazy.rs: once eight subtables had parsed, the list reserved room for every subtable of the lookup, in every nested lookup application. A 64 KB GSUB whose ChainContextFormat3 lookup of 32,000 offsets applies itself again from its tenth subtable peaked at 88.4 MiB (main 16.65 MiB, 0.23.1 103.5 MiB) and took 10.95 ms warm (main 5.65 ms). The reservation is now capped at 56 more entries; the reviewer measured 2.5 MiB with no reservation. New test a_walk_that_parses_a_few_subtables_holds_room_for_a_few.
  • src/sync.rs: the docs said every cache is a pure function of the font. The lookup accelerators' digests depend on the shared budget when they are built, so racing threads can keep different digests (memory and speed can follow call order); output does not change, since a lookup without a digest admits every glyph.

The review verified: 8-thread shaping on one Font (107,136 and 164,736 shapes, 0 differences from a fresh Font), every glyph's advances of 9 variable fonts from the caches (1,013,508 checks, 0 mismatches), accelerator supersets and the 1,024-coverage cap, memory proportional to table size, and a clean shape fuzz run (247,076 runs).

LazySubtables::has_cache counted the subtables that parse before an
index by scanning from 0 on every call, and the walk asks for it at
every class-based context subtable past the eighth for every glyph, so
a lookup of N such subtables cost O(N^2) per glyph. A GSUB of 32,000
offsets to one ChainContextFormat2 took 7 s to shape "aaaa".

The count now advances from where the last call left it and stops at
the eighth subtable that parses, which fixes the cutoff for the rest
of the application: each subtable is counted at most once per lookup
application. A unit test counts subtable reads per visit.

The walk also reads each subtable's slot once per visit (the
accelerator check now goes through LazySubtables::get_admitted), a
subtable that fails to parse takes no room in the parsed list, the
list reserves room for every subtable once a walk has parsed eight
(as parsing them all up front did, instead of copying at each
doubling), and parsing goes through a Subtable trait instead of
function pointers.
LookupAccel::build kept a 24-byte digest per subtable for every lookup
index a run applied, even after both reading budgets were spent, so
what a Font kept grew with lookup indices times subtables rather than
with the table: a 66 KB GSUB of 500 indices naming one lookup of 32,000
subtables kept 375 MB, and lookups that overlap in the table can make
a 96 KB GSUB keep 1.5 GB.

- Each digest kept costs one entry of the table's budget (twice the
  table's length plus 64 Ki), taken before any coverage is read. A
  lookup the budget cannot pay for keeps no digest and admits every
  glyph, which only means less skipping.
- Lookup indices with the same LookupList offset share one
  accelerator. Fonts compilers write list each lookup once at
  increasing offsets, which needs no sorting to tell.
- A lookup whose subtables all have the lookup's digest (one subtable,
  or many sharing a coverage) keeps only that: the walk checks the
  lookup's digest before any subtable, so theirs would rule nothing
  more out.

tests/hostile_layout_caches.rs builds such tables on Open Sans and
checks the bytes a used Font keeps and that it shapes as a fresh Font.
Once a table's 16 map slots held other (script, language,
FeatureVariations record) keys, every feature query resolved the whole
language system into a map and dropped it, more work per query than
the walk the cache replaces, and a font shaped in many languages did
that for most of its queries. Past the slots, a query now walks the
language system, as it does without a cache. The first 16 keys a font
is shaped with stay kept; a map and the walk answer alike, so which
keys those are never shows in the output.

The walk-equivalence test now gives each language its own cache, so
every key it checks is a kept map.
STABILITY.md says Font::coords is no longer a const fn, next to the
note that its slice now borrows the font. The crate docs, the README
and a new "Supported targets" section of STABILITY.md say the core
crate needs pointer-width atomics with compare-and-swap: the Font
caches are built through them, and OwnedFace and Language already
needed them through alloc::sync::Arc, so thumbv6m-none-eabi did not
build before 0.24.0 either.
shape_samples shapes every sample text with one Font, whose caches are
built from its second call on. Each text is now shaped with a fresh
Font too, run by run for bidi text, and the two outputs must be equal,
so a fuzzed font that makes the caches change the output crashes the
shape and shape_text targets.
A vertical run moves every glyph by half its horizontal advance, and
at non-default coordinates that advance comes from HVAR, whose delta
walks the variation store's regions per glyph. That walk ran for every
glyph of every call: about half of a 100-character Hahmlet[wght] TTB
call at wght 700, which made such runs slower than 0.23.0 although the
vertical origins themselves were cached.

The varied horizontal and vertical advances, from HVAR and VVAR as
from phantom points, now share the font's two advance caches (a font
has HVAR or not, so one instance only ever stores one kind), as
HarfBuzz keeps an advance cache per font. The vertical origins and
phantom-point advances now look in the font's cache before the call's
own maps, so a warm call adds no map entries. Horizontal runs of fonts
with HVAR get the cached advances too. The glyph caches stay at three
of at most 4096 entries each.
A lookup whose subtables all have the lookup's digest keeps none of
theirs, but its subtables were charged before that was known; the
docs now say so.
A lookup whose subtables keep no digests of their own (all of them have
the lookup's) admits every subtable at a glyph the walk stopped at, so
the walk no longer asks the accelerator per subtable there. An inline
slot past the last subtable reads as a subtable that does not parse,
so a slot lookup needs no bounds check besides the array's own, and
the cache rank check is inlined.
A Font's first call reads each applied lookup's subtable coverages up
front, so a hostile lookup of 32,000 subtables read 32,000 coverages per
application on top of parsing them. The first 1,024 are read now; the
subtables past them admit every glyph and are parsed when a glyph
reaches them, as every subtable was before the accelerators. Real
lookups stay far below the cap (Noto Sans KR's ccmp has 268).
Once eight subtables of a lookup had parsed, the list reserved room for every subtable of the lookup. Each nested lookup application holds its own list, so a lookup of 32,000 subtables that applies itself again held room for all of them at every level: 88 MiB on the #301 review's font, where the walk parsed ten per level. The reservation is now at most 56 more entries; past it the list grows by doubling. Also say in the OnceBox docs that the lookup accelerators can keep different digests depending on which thread builds them first, while shaping output does not change.
@albedosehen
albedosehen marked this pull request as ready for review October 5, 2026 01:24
@albedosehen
albedosehen merged commit 355a76a into main Oct 5, 2026
8 checks passed
@albedosehen albedosehen mentioned this pull request Oct 5, 2026
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.

Bound the shaping caches' work and memory on hostile fonts

1 participant