Skip to content

Commit fbe0cb3

Browse files
authored
[read-fonts] optimize post glyph name iter (#1983)
Custom glyph names in the `post` table are stored as an array of indices which reference a position in sequence of length prefixed (Pascal) strings. The "naive" approach to iteration, calling `glyph_name` for each gid, is quadratic over number of glyphs and leads to poor performance in the general case and potentially multi-second delays in the pathological case (all indices point to last name). FreeType and HarfBuzz both use a precomputed, heap allocated offset buffer to avoid the cost. Since we'd prefer to avoid allocations, this patch introduces a sparse offset "checkpoint" buffer (currently with 16 entries) that is filled lazily during iteration and limits the linear search space to `num_glyphs / 17` (the 0 offset is implicit). It also caches the index and offset for the last glyph which is used for the start of the linear scan for the next glyph if it is closer than the nearest checkpoint. This leads to very fast iteration when the indices are (mostly) monotonic. Also updates skrifa to use this. Benchmarks for "naive" vs new cached iterator with 8k glyphs): | Benchmark | Naive (ms) | Cached (ms) | Speedup | | :--- | :---: | :---: | :---: | | **Monotonic** | 45.241 | 0.164 | **276×** | | **Mostly Monotonic** | 43.016 | 0.177 | **243×** | | **Pathological** | 92.672 | 0.170 | **545×** | Increasing the glyph count to 20k shows the scaling behavior: | Benchmark | Naive (ms) | Cached (ms) | Speedup | | :--- | :---: | :---: | :---: | | **Monotonic** | 257.760 | 0.404 | **638×** | | **Mostly Monotonic** | 260.030 | 0.501 | **519×** | | **Pathological** | 530.350 | 0.413 | **1,285×** | Note that the worst-case for the two iterators is now different. The cached iterator easily handles the "all indices point to last" structure due to the monotonic optimization. The absolute worst-case timing is triggered by a structure where the first index points to the last glyph, while remaining indices alternate between the ends of the first and second buckets. This layout forces repeated scans across an entire bucket width. Comparing worst-case performance at 65k glyphs (all indices point to last for naive and the above layout for cached): | Benchmark | Naive (ms) | Cached (ms) | Speedup | | :--- | :---: | :---: | :---: | | **Worst Case** | 6,184.20 | 329.82 | **18.8×** | internal bug ref: b/537782685
1 parent 7255a6a commit fbe0cb3

5 files changed

Lines changed: 346 additions & 7 deletions

File tree

font-test-data/src/lib.rs

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,105 @@ pub mod closure {
158158
}
159159

160160
pub mod post {
161+
use crate::bebuffer::BeBuffer;
162+
163+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
164+
pub enum GlyphNameOrder {
165+
/// Glyph name indices in strictly increasing order.
166+
Monotonic,
167+
/// Glyph name indices in mostly increasing order, with some back
168+
/// references to previous names.
169+
///
170+
/// This likely matches most sane font data with duplicate glyph names.
171+
MostlyMonotonicWithBackrefs,
172+
/// All glyph name indices point to the last glyph name.
173+
///
174+
/// This is the patholical case, requiring a full scan for each glyph
175+
/// during iteration.
176+
AllPointToLast,
177+
}
178+
179+
/// Build a synthetic post v2 table with custom names for each glyph.
180+
///
181+
/// Returns the table bytes and the generated glyph names in glyph id order.
182+
pub fn v2_with_varied_glyph_names(
183+
num_glyphs: u16,
184+
base_name_len: u8,
185+
order: GlyphNameOrder,
186+
) -> (Vec<u8>, Vec<String>) {
187+
let custom_start = 258u16;
188+
let mut buf = BeBuffer::new()
189+
.push(0x0002_0000u32) // version 2.0
190+
.push(0u32) // italicAngle
191+
.push(0i16) // underlinePosition
192+
.push(0i16) // underlineThickness
193+
.push(0u32) // isFixedPitch
194+
.push(0u32) // minMemType42
195+
.push(0u32) // maxMemType42
196+
.push(0u32) // minMemType1
197+
.push(0u32) // maxMemType1
198+
.push(num_glyphs);
199+
let mapped_indices = mapped_custom_indices(num_glyphs as usize, order);
200+
for mapped_idx in mapped_indices.iter().copied() {
201+
let mapped_idx_u16 = u16::try_from(mapped_idx).unwrap();
202+
buf = buf.push(custom_start.saturating_add(mapped_idx_u16));
203+
}
204+
let mut custom_names = Vec::with_capacity(num_glyphs as usize);
205+
for custom_idx in 0..num_glyphs as usize {
206+
let name = varied_name(custom_idx, base_name_len);
207+
let len = u8::try_from(name.len()).unwrap();
208+
buf = buf.push(len).extend(name.as_bytes().iter().copied());
209+
custom_names.push(name);
210+
}
211+
let names = mapped_indices
212+
.iter()
213+
.map(|idx| custom_names[*idx].clone())
214+
.collect();
215+
(buf.data().to_vec(), names)
216+
}
217+
218+
fn mapped_custom_indices(num_glyphs: usize, order: GlyphNameOrder) -> Vec<usize> {
219+
match order {
220+
GlyphNameOrder::Monotonic => (0..num_glyphs).collect(),
221+
GlyphNameOrder::MostlyMonotonicWithBackrefs => (0..num_glyphs)
222+
.map(|gid| {
223+
if gid > 0 && gid % 97 == 0 {
224+
gid - 1
225+
} else if gid > 3 && gid % 251 == 0 {
226+
gid - 3
227+
} else if gid > 7 && gid % 509 == 0 {
228+
gid - 7
229+
} else {
230+
gid
231+
}
232+
})
233+
.collect(),
234+
GlyphNameOrder::AllPointToLast => {
235+
if num_glyphs == 0 {
236+
Vec::new()
237+
} else {
238+
vec![num_glyphs - 1; num_glyphs]
239+
}
240+
}
241+
}
242+
}
243+
244+
fn varied_name(custom_idx: usize, base_name_len: u8) -> String {
245+
let base_name_len = usize::from(base_name_len.clamp(8, 220));
246+
let len = (base_name_len + (custom_idx % 27)).min(255);
247+
let mut name = format!("glyph_{custom_idx:05}_");
248+
let pattern = match custom_idx % 4 {
249+
0 => "abcdefghijklmnopqrstuvwxyz0123456789",
250+
1 => "zyxwvutsrqponmlkjihgfedcba9876543210",
251+
2 => "aabccddeeffgghhiijjkkllmmnnooppqqrrss",
252+
_ => "n0nlinear_name_pattern_block_",
253+
};
254+
while name.len() < len {
255+
name.push_str(pattern);
256+
}
257+
name.truncate(len);
258+
name
259+
}
161260

162261
#[rustfmt::skip]
163262
pub static SIMPLE: &[u8] = &[

read-fonts/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,7 @@ harness = false
6565
[[bench]]
6666
name = "table_lookup"
6767
harness = false
68+
69+
[[bench]]
70+
name = "glyph_names"
71+
harness = false

read-fonts/benches/glyph_names.rs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
//! Benchmark that compares naive iteration over glyph names with optimized
2+
//! iteration using `Post::glyph_names`.
3+
4+
use core::hint::black_box;
5+
use criterion::{criterion_group, criterion_main, Criterion};
6+
use font_test_data::post::GlyphNameOrder;
7+
use read_fonts::{tables::post::Post, FontRead};
8+
9+
const NUM_GLYPHS: u16 = 8_000;
10+
const BASE_NAME_LEN: u8 = 20;
11+
12+
fn mode_name(mode: GlyphNameOrder) -> &'static str {
13+
match mode {
14+
GlyphNameOrder::Monotonic => "monotonic",
15+
GlyphNameOrder::MostlyMonotonicWithBackrefs => "mostly_monotonic_with_backrefs",
16+
GlyphNameOrder::AllPointToLast => "all_point_to_last",
17+
}
18+
}
19+
20+
/// Iterate glyph names by looping over indices and calling `Post::glyph_name`.
21+
pub fn glyph_names_iter_naive(c: &mut Criterion) {
22+
let modes = [
23+
GlyphNameOrder::Monotonic,
24+
GlyphNameOrder::MostlyMonotonicWithBackrefs,
25+
GlyphNameOrder::AllPointToLast,
26+
];
27+
for mode in modes {
28+
let post_data =
29+
font_test_data::post::v2_with_varied_glyph_names(NUM_GLYPHS, BASE_NAME_LEN, mode).0;
30+
let post = Post::read(post_data.as_slice().into()).unwrap();
31+
let bench_name = format!("glyph_names_iter_naive_synth_post_v2/{}", mode_name(mode));
32+
c.bench_function(&bench_name, |b| {
33+
b.iter(|| {
34+
let total_len: usize = (0..post.num_glyphs().unwrap())
35+
.filter_map(|gid| post.glyph_name(gid.into()))
36+
.map(str::len)
37+
.sum();
38+
black_box(total_len)
39+
});
40+
});
41+
}
42+
}
43+
44+
/// Use optimized `Post::glyph_names` iterator to iterate glyph names.
45+
pub fn glyph_names_iter(c: &mut Criterion) {
46+
let modes = [
47+
GlyphNameOrder::Monotonic,
48+
GlyphNameOrder::MostlyMonotonicWithBackrefs,
49+
GlyphNameOrder::AllPointToLast,
50+
];
51+
for mode in modes {
52+
let post_data =
53+
font_test_data::post::v2_with_varied_glyph_names(NUM_GLYPHS, BASE_NAME_LEN, mode).0;
54+
let post = Post::read(post_data.as_slice().into()).unwrap();
55+
let bench_name = format!("glyph_names_iter_cached_synth_post_v2/{}", mode_name(mode));
56+
c.bench_function(&bench_name, |b| {
57+
b.iter(|| {
58+
let total_len: usize = post.glyph_names().map(|(_, name)| name.len()).sum();
59+
black_box(total_len)
60+
});
61+
});
62+
}
63+
}
64+
65+
criterion_group!(benches, glyph_names_iter_naive, glyph_names_iter);
66+
criterion_main!(benches);

read-fonts/src/tables/post.rs

Lines changed: 168 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,13 @@ impl<'a> Post<'a> {
1313
}
1414
}
1515

16-
pub fn glyph_name(&self, glyph_id: GlyphId16) -> Option<&str> {
16+
/// Returns the name for the given glyph.
17+
///
18+
/// Note that this is a relatively expensive operation, as it may require
19+
/// a linear scan through the string data to find the target name. If you
20+
/// need to iterate over all glyph names or collect them into a map for
21+
/// faster access, use [`Self::glyph_names`] instead.
22+
pub fn glyph_name(&self, glyph_id: GlyphId16) -> Option<&'a str> {
1723
let glyph_id = glyph_id.to_u16() as usize;
1824
match self.version() {
1925
Version16Dot16::VERSION_1_0 => DEFAULT_GLYPH_NAMES.get(glyph_id).copied(),
@@ -29,6 +35,24 @@ impl<'a> Post<'a> {
2935
}
3036
}
3137

38+
/// Return an iterator over the glyph names in this table.
39+
pub fn glyph_names(&self) -> GlyphNames<'a> {
40+
let num_names = self.num_names() as u32;
41+
let kind = match self.version() {
42+
Version16Dot16::VERSION_1_0 => GlyphNameIterKind::V1(self.clone(), 0),
43+
Version16Dot16::VERSION_2_0 => GlyphNameIterKind::V2 {
44+
post: self.clone(),
45+
idx: 0,
46+
checkpoint_stride: (num_names as usize).div_ceil(NUM_CHECKPOINTS + 1).max(1),
47+
checkpoints: [UNSET_CHECKPOINT; NUM_CHECKPOINTS],
48+
last_actual_idx: None,
49+
last_offset: 0,
50+
},
51+
_ => GlyphNameIterKind::None,
52+
};
53+
GlyphNames { num_names, kind }
54+
}
55+
3256
//FIXME: how do we want to traverse this? I want to stop needing to
3357
// add special cases for things...
3458
#[cfg(feature = "experimental_traverse")]
@@ -88,6 +112,126 @@ impl VarSize for PString<'_> {
88112
type Size = u8;
89113
}
90114

115+
const NUM_CHECKPOINTS: usize = 16;
116+
const UNSET_CHECKPOINT: u32 = u32::MAX;
117+
118+
/// Iterator over the glyph names in a post table.
119+
#[derive(Clone)]
120+
pub struct GlyphNames<'a> {
121+
num_names: u32,
122+
kind: GlyphNameIterKind<'a>,
123+
}
124+
125+
#[derive(Clone)]
126+
enum GlyphNameIterKind<'a> {
127+
None,
128+
V1(Post<'a>, u32),
129+
V2 {
130+
post: Post<'a>,
131+
idx: u32,
132+
// The number of indices between checkpoints
133+
checkpoint_stride: usize,
134+
// The offset of each checkpoint; checkpoint 0 is implicit
135+
checkpoints: [u32; NUM_CHECKPOINTS],
136+
// The last actual index and that was scanned, for monotonic fast path
137+
last_actual_idx: Option<usize>,
138+
// The offset associated with the last scanned index
139+
last_offset: usize,
140+
},
141+
}
142+
143+
impl<'a> Iterator for GlyphNames<'a> {
144+
type Item = (GlyphId, &'a str);
145+
146+
fn next(&mut self) -> Option<Self::Item> {
147+
match &mut self.kind {
148+
GlyphNameIterKind::None => None,
149+
GlyphNameIterKind::V1(post, idx) => {
150+
if *idx >= self.num_names {
151+
return None;
152+
}
153+
let gid = GlyphId16::new(*idx as u16);
154+
let name = post.glyph_name(gid)?;
155+
*idx += 1;
156+
Some((gid.into(), name))
157+
}
158+
GlyphNameIterKind::V2 {
159+
post,
160+
idx,
161+
checkpoint_stride,
162+
checkpoints,
163+
last_actual_idx,
164+
last_offset,
165+
} => {
166+
if *idx >= self.num_names {
167+
return None;
168+
}
169+
let stride = *checkpoint_stride;
170+
let gid = GlyphId16::new(*idx as u16);
171+
let mut actual_idx = post.glyph_name_index()?.get(*idx as usize)?.get() as usize;
172+
let name = if actual_idx < DEFAULT_GLYPH_NAMES.len() {
173+
DEFAULT_GLYPH_NAMES.get(actual_idx).copied()?
174+
} else {
175+
actual_idx -= DEFAULT_GLYPH_NAMES.len();
176+
let string_data = post.data.slice(post.string_data_byte_range())?;
177+
// Checkpoint 0 is implicit and always at offset 0; the
178+
// array stores logical checkpoints 1..=NUM_CHECKPOINTS.
179+
let target_slot = (actual_idx / stride).min(NUM_CHECKPOINTS);
180+
// Find the the starting location for our scan
181+
let (mut scan_idx, mut offset) = {
182+
// Search backward from the target slot to find the
183+
// nearest checkpoint that has been set
184+
let mut slot = target_slot;
185+
while slot > 0 && checkpoints[slot - 1] == UNSET_CHECKPOINT {
186+
slot -= 1;
187+
}
188+
if slot == 0 {
189+
// Fallback to implicit checkpoint 0
190+
(0, 0)
191+
} else {
192+
// Otherwise, start scanning from the nearest
193+
// checkpoint
194+
(slot * stride, checkpoints[slot - 1] as usize)
195+
}
196+
};
197+
// See if we can use the monotonic fast path.
198+
if let Some(last_idx) = *last_actual_idx {
199+
// Start scanning from the last index if that provides
200+
// a smaller search space than the nearest checkpoint
201+
if last_idx <= actual_idx && last_idx > scan_idx {
202+
scan_idx = last_idx;
203+
offset = *last_offset;
204+
}
205+
}
206+
// Now do the linear scan over the string data
207+
while scan_idx < actual_idx {
208+
let item_len = PString::read_len_at(string_data, offset)?;
209+
offset = offset.checked_add(item_len)?;
210+
scan_idx += 1;
211+
// If this index is a checkpoint, record the offset
212+
// for future scans
213+
if scan_idx % stride == 0 {
214+
let slot = (scan_idx / stride).min(NUM_CHECKPOINTS);
215+
if slot > 0 {
216+
checkpoints[slot - 1] = u32::try_from(offset).ok()?;
217+
}
218+
}
219+
}
220+
if actual_idx % stride == 0 && target_slot > 0 {
221+
checkpoints[target_slot - 1] = u32::try_from(offset).ok()?;
222+
}
223+
// Record the last index and offset for future scans
224+
*last_actual_idx = Some(actual_idx);
225+
*last_offset = offset;
226+
PString::read(string_data.split_off(offset)?).ok()?.0
227+
};
228+
*idx += 1;
229+
Some((gid.into(), name))
230+
}
231+
}
232+
}
233+
}
234+
91235
/// The 258 glyph names defined for Macintosh TrueType fonts
92236
#[rustfmt::skip]
93237
pub static DEFAULT_GLYPH_NAMES: [&str; 258] = [
@@ -206,4 +350,27 @@ mod tests {
206350
// Just don't panic
207351
assert_eq!(post.glyph_name(GlyphId16::new(0)), None);
208352
}
353+
354+
#[test]
355+
fn glyph_names_matches_naive_on_varied_synthetic_v2_data() {
356+
let num_glyphs = 2_000u16;
357+
let orders = [
358+
test_data::GlyphNameOrder::Monotonic,
359+
test_data::GlyphNameOrder::MostlyMonotonicWithBackrefs,
360+
test_data::GlyphNameOrder::AllPointToLast,
361+
];
362+
for order in orders {
363+
let (bytes, expected_custom_names) =
364+
test_data::v2_with_varied_glyph_names(num_glyphs, 63, order);
365+
let post = Post::read(bytes.as_slice().into()).unwrap();
366+
let from_naive: Vec<_> = (0..num_glyphs)
367+
.map(|gid| post.glyph_name(GlyphId16::new(gid)).unwrap())
368+
.collect();
369+
let from_iter: Vec<_> = post.glyph_names().map(|(_, name)| name).collect();
370+
assert_eq!(from_iter, from_naive);
371+
for (name, expected) in from_iter.iter().zip(expected_custom_names.iter()) {
372+
assert_eq!(*name, expected.as_str());
373+
}
374+
}
375+
}
209376
}

0 commit comments

Comments
 (0)