M9: parallel composition scales (Copy FaceRef instead of per-glyph Arc clones; BMP coverage bitset; read-locked word cache)

Cold compose of 1M chars on 8+ threads: ~255 ms -> ~38 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-01 12:41:29 -04:00
co-authored by Claude Opus 5.5
parent 12e502c203
commit 8e4403af38
4 changed files with 119 additions and 36 deletions
+3 -3
View File
@@ -16,13 +16,13 @@ pub mod shape;
use std::collections::HashMap;
use std::ops::Range;
use std::sync::{Arc, Mutex};
use std::sync::Mutex;
use designcraft_doc::{
Align, Composer, Document, FirstBaseline, GridAlign, ItemId, ParaProps, SpanColumns, StartParagraph, Story, StoryId, TabAlign, TextFrameOptions,
VerticalJustification, WrapMode, story,
};
use designcraft_fonts::{FontDb, FontFace};
use designcraft_fonts::FontDb;
use designcraft_geom::{Point, Rect};
use crate::breaker::{Break, Spacing};
@@ -46,7 +46,7 @@ pub struct RunStyle {
/// A positioned glyph. `x` is absolute in frame inner space; `y` is relative to the line baseline.
#[derive(Clone, Debug)]
pub struct PlacedGlyph {
pub face: Arc<FontFace>,
pub face: designcraft_fonts::FaceRef,
pub gid: u32,
pub x: f64,
pub y: f64,
+48 -32
View File
@@ -1,17 +1,17 @@
//! Paragraph → styled, positioned glyphs (before line breaking).
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::sync::{Arc, RwLock};
use designcraft_doc::{Capitalization, CharProps, Kerning, Leading, Position, Story, Styles, story};
use designcraft_fonts::{Feature, FontDb, FontFace, ShapedGlyph, feature};
use designcraft_fonts::{FaceRef, Feature, FontDb, FontFace, ShapedGlyph, feature};
use crate::RunStyle;
/// One glyph with every character attribute applied, in points.
#[derive(Clone, Debug)]
pub struct Glyph {
pub face: Arc<FontFace>,
pub face: FaceRef,
pub gid: u32,
/// Story byte offset of the cluster and its byte length (0 for generated glyphs).
pub byte: usize,
@@ -229,11 +229,29 @@ fn shape_run(
}
type WordMap = HashMap<Box<str>, Arc<[ShapedGlyph]>>;
type WordKey = (u32, bool, String);
/// Shaped words (font units) by (face, caps, features), then text.
static WORD_CACHE: Mutex<Vec<((u32, bool, String), WordMap)>> = Mutex::new(Vec::new());
/// Shaped words (font units) by (face, caps, features), then text. Lookups take read locks and
/// misses are shaped outside any lock, so parallel composition doesn't serialize here.
static WORD_CACHE: RwLock<Vec<(WordKey, Arc<RwLock<WordMap>>)>> = RwLock::new(Vec::new());
const WORD_CACHE_MAX: usize = 50_000;
fn word_map(key: WordKey) -> Arc<RwLock<WordMap>> {
if let Some((_, m)) = WORD_CACHE.read().unwrap_or_else(|e| e.into_inner()).iter().find(|e| e.0 == key) {
return m.clone();
}
let mut w = WORD_CACHE.write().unwrap_or_else(|e| e.into_inner());
if let Some((_, m)) = w.iter().find(|e| e.0 == key) {
return m.clone();
}
if w.len() > 64 {
w.clear();
}
let m = Arc::new(RwLock::new(HashMap::new()));
w.push((key, m.clone()));
m
}
/// Shape `src` word by word through a cache (like a browser's word cache): the text is split
/// after each U+0020 so repeated words are shaped once. Shaping does not cross word spaces in the
/// scripts we lay out, and clusters stay byte offsets into `src`.
@@ -242,34 +260,31 @@ fn shape_cached(face: &FontFace, src: &str, feats: &[Feature], caps: bool) -> Ve
if src.len() < 2 || !src.contains(' ') {
return designcraft_fonts::shape(face, src, feats, map);
}
let fkey = format!("{feats:?}");
let mut out = Vec::with_capacity(src.len());
let mut guard = WORD_CACHE.lock().unwrap_or_else(|e| e.into_inner());
let key = (face.id(), caps, fkey);
let idx = match guard.iter().position(|e| e.0 == key) {
Some(i) => i,
None => {
if guard.len() > 64 {
guard.clear();
}
guard.push((key, HashMap::new()));
guard.len() - 1
}
let words = word_map((face.id(), caps, format!("{feats:?}")));
let pieces: Vec<&str> = src.split_inclusive(' ').collect();
let mut found: Vec<Option<Arc<[ShapedGlyph]>>> = {
let r = words.read().unwrap_or_else(|e| e.into_inner());
pieces.iter().map(|p| r.get(*p).cloned()).collect()
};
let cache = &mut guard[idx].1;
if cache.len() > WORD_CACHE_MAX {
cache.clear();
let mut fresh: Vec<(Box<str>, Arc<[ShapedGlyph]>)> = Vec::new();
for (k, f) in found.iter_mut().enumerate() {
if f.is_none() {
let g: Arc<[ShapedGlyph]> = designcraft_fonts::shape(face, pieces[k], feats, map).into();
fresh.push((pieces[k].into(), g.clone()));
*f = Some(g);
}
}
if !fresh.is_empty() {
let mut w = words.write().unwrap_or_else(|e| e.into_inner());
if w.len() + fresh.len() > WORD_CACHE_MAX {
w.clear();
}
w.extend(fresh);
}
let mut out = Vec::with_capacity(src.len());
let mut start = 0;
for piece in src.split_inclusive(' ') {
let glyphs = match cache.get(piece) {
Some(g) => g.clone(),
None => {
let g: Arc<[ShapedGlyph]> = designcraft_fonts::shape(face, piece, feats, map).into();
cache.insert(piece.into(), g.clone());
g
}
};
for (piece, glyphs) in pieces.iter().zip(found) {
let glyphs = glyphs.unwrap_or_else(|| Arc::from(Vec::new()));
out.extend(glyphs.iter().map(|g| ShapedGlyph { cluster: g.cluster + start, ..*g }));
start += piece.len();
}
@@ -299,7 +314,7 @@ fn effective_size(p: &CharProps) -> (f64, f64) {
fn control_glyph(face: &Arc<FontFace>, p: &CharProps, auto_leading: f64, style: u32, byte: usize, ch: char) -> Glyph {
let (size, k, ascent, descent, leading, cap, xh, shift) = metrics(face, p, auto_leading);
Glyph {
face: face.clone(),
face: FaceRef::of(face),
gid: face.glyph_for(' '),
byte,
len: ch.len_utf8(),
@@ -345,6 +360,7 @@ fn shape_segment(
let space = face.advance(face.glyph_for(' ')) * k * hs;
let shaped: Vec<ShapedGlyph> = shape_cached(face, src, &feats, caps);
let n = shaped.len();
let fref = FaceRef::of(face);
for (gi, sg) in shaped.iter().enumerate() {
let (byte, len, ch) = if replacement.is_some() {
(range.start, if gi == 0 { range.len() } else { 0 }, text[range.start..].chars().next().unwrap_or(' '))
@@ -363,7 +379,7 @@ fn shape_segment(
// Only the first glyph of a cluster owns the bytes (so ranges partition the text).
let first_in_cluster = gi == 0 || shaped[gi - 1].cluster != sg.cluster;
out.push(Glyph {
face: face.clone(),
face: fref,
gid: sg.gid,
byte,
len: if first_in_cluster || replacement.is_some() { len } else { 0 },
+67
View File
@@ -57,6 +57,57 @@ pub struct FontFace {
pub cap_height: f64,
pub x_height: f64,
pub shaper: harfrust::ShaperData,
/// Basic Multilingual Plane coverage bitset, built on first use.
bmp: std::sync::OnceLock<Box<[u64]>>,
}
/// A cheap `Copy` handle to a face. Faces are never unloaded, so the handle lives for the rest of
/// the process; unlike cloning an `Arc`, copying it touches no shared reference count (which made
/// parallel composition scale negatively).
#[derive(Clone, Copy)]
pub struct FaceRef(&'static FontFace);
impl FaceRef {
/// The handle for a loaded face (memoised per face).
pub fn of(face: &Arc<FontFace>) -> FaceRef {
static LEAKED: RwLock<Vec<Option<&'static FontFace>>> = RwLock::new(Vec::new());
let id = face.id as usize;
if let Some(Some(f)) = LEAKED.read().unwrap_or_else(|e| e.into_inner()).get(id) {
return FaceRef(f);
}
let mut w = LEAKED.write().unwrap_or_else(|e| e.into_inner());
if w.len() <= id {
w.resize(id + 1, None);
}
let f: &'static FontFace = w[id].unwrap_or_else(|| {
let keep: &'static Arc<FontFace> = Box::leak(Box::new(face.clone()));
keep
});
w[id] = Some(f);
FaceRef(f)
}
pub fn get(self) -> &'static FontFace {
self.0
}
}
impl std::ops::Deref for FaceRef {
type Target = FontFace;
fn deref(&self) -> &FontFace {
self.0
}
}
impl std::fmt::Debug for FaceRef {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
impl PartialEq for FaceRef {
fn eq(&self, o: &FaceRef) -> bool {
self.0.id == o.0.id
}
}
impl std::fmt::Debug for FontFace {
@@ -88,6 +139,21 @@ impl FontFace {
}
/// Does the face map `c` to a glyph?
pub fn covers(&self, c: char) -> bool {
let cp = c as u32;
if cp < 0x1_0000 {
let bits = self.bmp.get_or_init(|| {
let mut b = vec![0u64; 1024].into_boxed_slice();
if let Some(f) = self.skrifa() {
for (cp, _) in f.charmap().mappings() {
if cp < 0x1_0000 {
b[(cp / 64) as usize] |= 1 << (cp % 64);
}
}
}
b
});
return bits[(cp / 64) as usize] & (1 << (cp % 64)) != 0;
}
self.skrifa().is_some_and(|f| f.charmap().map(c).is_some())
}
/// Units per em.
@@ -223,6 +289,7 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String) -> Opt
shaper,
bytes,
index,
bmp: std::sync::OnceLock::new(),
})
}
+1 -1
View File
@@ -8,7 +8,7 @@
mod fontdb;
pub use fontdb::{FALLBACK_FAMILY, FontDb, FontFace};
pub use fontdb::{FALLBACK_FAMILY, FaceRef, FontDb, FontFace};
pub use harfrust::Feature;
use harfrust::{Direction, ShapeOptions, Tag, UnicodeBuffer};
pub use kurbo::BezPath;