Skip to content

Optimize calculation of font coverage maps - #8676

Open
IgKh wants to merge 2 commits into
typst:mainfrom
IgKh:font-coverage-optimization
Open

IgKh wants to merge 2 commits into
typst:mainfrom
IgKh:font-coverage-optimization

Conversation

@IgKh

@IgKh IgKh commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

A large chunk of the cold start time of the typst CLI is the initialization of the font book by scanning system fonts. Profiling has shown that the run time of this phase is entirely dominated by calculating the font coverage maps, that indicate to the compiler which fonts have a glyph mapped for a specific Unicode code point.

Currently this is done by collecting the individual codepoints covered by every scanned font face into a large vector, and then compressing it into a compact range-based representation. To do so, the vector is de-duplicated and sorted, which is required because it is assembled by reading each CMAP sub-table in turn, and very often more than one sub-table can include a glyph for the same codepoint. These vectors include tens, or even hundreds, of thousands of entries; both the incremental allocation of the vector (causing multiple re-allocations and copies) and the sorting have large overhead.

We can avoid that through the observation that the codepoints in each sub-table already come out of ttf-parser unique and sorted (though see caveat). With this we can directly collect the codepoints covered by each sub-table into the compact representation without needing to keep a vector of all codepoints in memory, and form the final coverage map by taking the union of all the sub-table coverage maps. The union is calculated with a typical linear sorted array merge algorithm.

Benchmarks

Measurements taken with hyperfine (excluding warmup) with the dev-fast build configuration on a circa-2019 Intel-based Linux laptop with a relatively typical selection of 289 font families installed.

Comparing time to run typst fonts:

Benchmark 1: typst-old fonts
  Time (mean ± σ):     389.7 ms ±  10.8 ms    [User: 339.8 ms, System: 47.4 ms]
  Range (min … max):   375.6 ms … 407.6 ms    10 runs

Benchmark 2: typst-new fonts
  Time (mean ± σ):     251.4 ms ±  13.2 ms    [User: 206.1 ms, System: 43.4 ms]
  Range (min … max):   234.4 ms … 273.0 ms    12 runs

Summary
  typst-new fonts ran 1.55 ± 0.09 times faster than typst-old fonts

Comparing the time to cold compile a small "Hello World" document to PDF:

Benchmark 1: typst-old compile test.typ
  Time (mean ± σ):     395.0 ms ±  12.8 ms    [User: 344.0 ms, System: 49.4 ms]
  Range (min … max):   378.0 ms … 422.4 ms    10 runs

Benchmark 2: typst-new compile test.typ
  Time (mean ± σ):     256.2 ms ±  12.2 ms    [User: 213.8 ms, System: 42.4 ms]
  Range (min … max):   243.4 ms … 282.5 ms    10 runs

Summary
  typst-new compile test.typ ran 1.54 ± 0.09 times faster than typst-old compile test.typ

We can see that on average this PR shaves ~140ms from the CLI run time, and is likely to be far more significant on pathological cases such as #8561.

Caveat

As noted above, this relies on the observation that "the codepoints in each sub-table already come out of ttf-parser unique and sorted". This is actually not a structural property of all CMAP sub-table formats, e.g. formats 4 and 12 could be stored in an unsorted order or have duplicates. However this appears to be at least an implicit requirement of the TrueType/OpenType specifications, as the CMAP formats are expected to be laid out in a way that allows font engines to binary search for the corresponding glyph for a codepoint. It is possible that there are broken fonts out there that don't maintain this expectation, but I didn't find any among the fonts available to me.

@IgKh
IgKh force-pushed the font-coverage-optimization branch from f59a745 to 17cfb98 Compare July 23, 2026 12:58
@laurmaedje laurmaedje added waiting-on-review This PR is waiting to be reviewed. text Related to the text category, which is all about text handling, shaping, etc. cli About Typst's command line interface. perf Related to performance and optimization. labels Jul 23, 2026

@isuffix isuffix left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Overall this is quite nice! The implementation seems correct, and I can confirm the performance benefit when parsing for my local fonts 0.20s -> 0.10s, google-fonts 0.23s -> 0.16s and nerd-fonts 0.52s -> 0.21s.

I did add quite a few simplification and code style comments below that you should address. Feel free to let me know if you disagree with any of the smaller changes, our goal is just to ensure the code is idiomatic and maintainable :)

Stats

I also found some statistics to try to understand the performance effect myself. For the 17 embedded font faces (from the 4 embedded fonts), we have:

  • Unique codepoints per face: average 3240.8, min 1696 (Libertinus), max 4989 (NCMM)
  • The initial coverage vector length after 1 subtable: average 308.5, min 148, max 508
    • Note that the number of codepoint ranges is the length divided by 2
  • The coverage vector length increases exactly once after the initial subtable for each face:
    • NCMM (3 font faces) increases the length by 124, 126, and 126
    • The remaining 14 font faces increase the length by either 2, 4, or 6
  • Total subtables were between 4 and 6, always with at least two pairs of duplicated mappings

So the coverage vector ends up usually being around 10x smaller than the total number of codepoints, and repeating the coverage vector building/merging for 3-5 subtables is cheaper than getting all of the codepoints first and sorting them before merging.

Iterating by Ranges

This isn't something to change with this PR, but we should really be iterating over codepoint ranges instead of iterating individual codepoints.

Most cmap subtable formats store ranges already. Right now we're effectively just asking ttf-parser to expand those ranges into codepoints before we then manually contract them. And a malicious font with a format 13 subtable could easily include every valid and invalid(!) codepoint to pessimize the coverage builder. I expect this is the likely cause of the 50s load time in #8561, since I don't get anywhere close to that even with all of google-fonts and nerd-fonts (~4000 ttf files, 0.99s -> 0.47s).

ttf-parser doesn't expose the raw codepoint ranges itself, and given that ttf-parser is now in maintenance mode, that's unlikely to change. It does look like fontations exposes cmap subtable internals more directly. So iterating over ranges is likely blocked on #8172, which is going to need a bit of work to properly revive.

Comment thread crates/typst-library/src/text/font/info.rs Outdated
Comment thread crates/typst-library/src/text/font/info.rs Outdated
Comment thread crates/typst-library/src/text/font/info.rs Outdated
Comment thread crates/typst-library/src/text/font/info.rs Outdated
Comment thread crates/typst-library/src/text/font/info.rs Outdated
Comment on lines +331 to +332
/// Returns an encoding of the set of codepoints covered by either `self` or `other`.
pub fn union(&self, other: &Coverage) -> Self {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Three suggestions for this signature:

  1. I think we should remove pub since Coverage isn't really meant to be a library type
    • If we really want a library type, we should probably build/expose an ICU CodePointInversionList instead, although the builder doesn't have an optimized interface for our use-case :(
  2. After removing pub, we can have this take self and other by value since our only caller doesn't actually need to reuse either coverage vector
    • Although see below for more thoughts on optimizing CoverageBuilder::new()
  3. I would prefer merge over union since it's a verb and more directly describes the function's internals, but I'm fine with union as long as the doc-comment is updated to mention that it merges the two vectors

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

  1. Done
  2. I don't think so, there is not actual benefit from moving ownership of self and other; When there is no actual need for that you get needless_pass_by_value from Clippy. Doubly so since it is no longer public, so the signature can be later changed at will (not that a piece of API in the compiler being public ever stopped it from being refactored).
  3. union is IMO the correct method name. Coverage is a set (and the fact that it is implemented with a Vector doesn't remove from that), and the operation of merging two sets is always called union...

Comment thread crates/typst-library/src/text/font/info.rs Outdated
Comment thread crates/typst-library/src/text/font/info.rs Outdated
Comment thread crates/typst-library/src/text/font/info.rs
Comment on lines +387 to +389
fn new() -> Self {
Self { runs: vec![], next: 0 }
}

@isuffix isuffix Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

We should probably optimize building this instead of always creating a new vector with an empty capacity, but I'm not sure of the best approach.

One way is to replace new by with_capacity(capacity: usize) -> Self and call Vec::with_capacity(capacity). Then subtable_coverage could be initialized as CoverageBuilder::with_capacity(coverage.0.len()). This is the easier option, but it does have to call into the allocator to construct and destruct the vector on every loop.

The other way is to add a reset(&mut self) method that would set self.next = 0 and call self.runs.clear(). Then we could move subtable_coverage out of the subtable loop, call subtable_coverage.reset() at the loop start, and have merge take &mut subtable_coverage instead of trying to .build() it each time. But I feel like this makes the code kind of ugly, so it may be worth the first approach to keep the code simple.

LMK what your preference is or if you don't think it's worth changing.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I would not worry too much about calling into the allocator. It's usually cheaper than it feels like.

@IgKh IgKh Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It is probably not worth the hassle. The main difference is from doing things O(|ranges|) times rather than O(|codepoints|). Further tweaking things in the prior category is really in the micro-optimization territory.

That said, the pre-compiled CLI is built for the musl target which has a notoriously bad memory allocator. Though I think still not worth it.

@laurmaedje laurmaedje added waiting-on-author Pull request waits on author and removed waiting-on-review This PR is waiting to be reviewed. labels Aug 26, 2026
@IgKh
IgKh force-pushed the font-coverage-optimization branch from 17cfb98 to a429d33 Compare August 26, 2026 19:49
@IgKh

IgKh commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

@isuffix Many thanks for the very educational review. I've implemented most of your suggestions.

@laurmaedje laurmaedje added waiting-on-review This PR is waiting to be reviewed. and removed waiting-on-author Pull request waits on author labels Aug 27, 2026

@isuffix isuffix left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Only some smaller style notes now. Otherwise, I'm happy with the PR :)

Comment thread crates/typst-library/src/text/font/info.rs Outdated
Comment thread crates/typst-library/src/text/font/info.rs Outdated
Comment thread crates/typst-library/src/text/font/info.rs Outdated
Comment thread crates/typst-library/src/text/font/info.rs Outdated
Comment thread crates/typst-library/src/text/font/info.rs Outdated
Comment thread crates/typst-library/src/text/font/info.rs
@laurmaedje laurmaedje added waiting-on-author Pull request waits on author and removed waiting-on-review This PR is waiting to be reviewed. labels Sep 11, 2026
@IgKh

IgKh commented Sep 22, 2026

Copy link
Copy Markdown
Contributor Author

@isuffix Hi, thanks again for the review. To be quite honest, the interesting part of this PR is now over, I don't have much vested interest in this being merged or not, and I've already blown through the amount of my limited FOSS time that I was willing to spend on it. I don't want to spend another back-and-forth cycle.

The formatting was all done by cargo fmt, which is also checked by the CI. If it passes, but still not to the project's requirements it probably means that the configuration for rustfmt should be tightened. Likewise, I passed it by clippy on its' pedantic setting.

Feel free to make any changes you'd like to this patch yourself, or drop it. I'm OK either way. Thanks!

@isuffix

isuffix commented Sep 23, 2026

Copy link
Copy Markdown
Collaborator

That's alright! Thanks for responding as much as you have. I've pushed a commit with my recently reviewed changes and I'll take on getting this merged. I really appreciate you letting us know that we can move this forward. That's sometimes hard to accept/admit, so thank you.

Also note that some of the formatting problems are real limitations of rustfmt. For example, comment wrapping is blocked on stabilizing wrap_comments since we don't want to require nightly Rust. It's the kind of ecosystem problem that's hard to solve well except by waiting. (I also tried running it on the repo, and wrap_comments has a bunch of weird edge cases like typst-syntax/src/kind.rs::mode_after that make me feel like we shouldn't blindly trust it.)

@laurmaedje laurmaedje added ready-for-final-review Reviewed by team member or trusted contributor and ready for final look / merge and removed waiting-on-author Pull request waits on author labels Sep 23, 2026
/// Add a single codepoint to the set being built. Codepoints must be added
/// in a strictly increasing order.
fn add_codepoint(&mut self, codepoint: u32) {
debug_assert!(codepoint >= self.next, "Codepoints provided in wrong order");

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

can this panic for malformed fonts? If yes, then it should not be an assertion as assertions should only trigger for bugs in Typst.

@laurmaedje laurmaedje added waiting-for-completion The PR is waiting to be finished by a team member. and removed ready-for-final-review Reviewed by team member or trusted contributor and ready for final look / merge labels Sep 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cli About Typst's command line interface. perf Related to performance and optimization. text Related to the text category, which is all about text handling, shaping, etc. waiting-for-completion The PR is waiting to be finished by a team member.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants