From fc163e62bf2b25ddc5b4cc94fe4c3355e5721e32 Mon Sep 17 00:00:00 2001 From: "shingo.imota" Date: Thu, 16 Jul 2026 21:49:53 +0900 Subject: [PATCH] perf(font): lazy-load CJK fallback fonts to cut ~106MB idle RSS Resolving the ~18 curated CJK fallback names at startup faulted whole .ttc files into the page cache (Hiragino Sans GB 44.8MB, Hiragino Kaku Gothic W3 30.0MB, AppleGothic 29.2MB, ...), keeping ~104MB of clean file-backed font pages resident even when no CJK glyph is ever drawn. Measured idle RSS: 239MB -> 133MB; phys_footprint unchanged (the pages never counted there), closing the ~100MB RSS gap vs Ghostty. Drop the eager CJK loop from load_font_stack_with_primary and resolve a CJK codepoint on first stack miss instead: cjk_fallback_face_for walks the same PostScript-name-then-family priority list the eager stack used (so glyph selection is unchanged), gated by cmap coverage, ahead of the generic CoreText cascade. Resolution uses the existing no-slurp CoreText->mmap path, so even after CJK is drawn only touched pages become resident (Hiragino W3: 0.5MB vs a constant 30MB before). Verified end-to-end in the bundled app: Japanese kanji/kana render identically via the lazy path, with vmmap confirming no CJK font is mapped at idle. --- crates/noa-font/src/face.rs | 126 +++++++++++++++++++++++++++++++++--- crates/noa-font/src/grid.rs | 117 +++++++++++++++++++++++++++------ 2 files changed, 215 insertions(+), 28 deletions(-) diff --git a/crates/noa-font/src/face.rs b/crates/noa-font/src/face.rs index c4d7fea..69ed7cf 100644 --- a/crates/noa-font/src/face.rs +++ b/crates/noa-font/src/face.rs @@ -378,15 +378,15 @@ pub fn load_font_stack_with_primary( // private glyph) — see `embedded_symbols_nerd_font_face`. push_some_face(&mut fallbacks, embedded_symbols_nerd_font_face()); - for postscript_name in cjk_fallback_postscript_names() { - push_some_face( - &mut fallbacks, - postscript_fallback_face(&source, postscript_name), - ); - } - for family_name in cjk_fallback_family_names() { - push_some_face(&mut fallbacks, family_fallback_face(&source, family_name)); - } + // CJK fallbacks are NOT loaded here. Resolving the ~18 curated CJK + // families/faces at startup faulted whole `.ttc` files into the page cache + // (Hiragino Sans GB 44.8 MB, ヒラギノ角ゴシック W3 30.0 MB, AppleGothic + // 29.2 MB, …) — ~100 MB of idle RSS even when no CJK glyph is ever drawn. + // Instead a CJK codepoint that misses the stack pulls in exactly the one + // font that covers it, lazily, via [`cjk_fallback_face_for`] (called from + // `FontGrid` on a stack miss, ahead of the generic system cascade). The + // priority order there is identical to the eager list this replaced, so + // which font wins for a given CJK codepoint is unchanged. Ok(FontStack::new( primary, @@ -881,6 +881,42 @@ pub(crate) fn cascade_fallback_face(_ch: char) -> Option { None } +/// Lazily resolve the first curated CJK fallback face that covers `ch`, in +/// the exact priority order the eager stack used to load them +/// ([`cjk_fallback_postscript_names`] then [`cjk_fallback_family_names`]) — so +/// which font wins for a given CJK codepoint is unchanged from when the whole +/// CJK list was resolved at startup. +/// +/// Called by `FontGrid` on a stack miss, *before* the generic macOS system +/// cascade ([`cascade_fallback_face`]), and its result is pushed into the +/// stack and cached — so no CJK font file is resolved or mapped until a +/// codepoint one of these fonts covers is actually drawn. Each candidate +/// resolves via the no-slurp CoreText→mmap path ([`postscript_fallback_face`] +/// / [`family_fallback_face`]); probing a candidate that does not cover `ch` +/// only faults that font's `cmap`, and the winning font stays memory-mapped so +/// only its touched glyph pages ever become resident. +/// +/// Returns `None` when no curated CJK font covers `ch` (the caller then tries +/// the generic system cascade). +pub(crate) fn cjk_fallback_face_for(ch: char) -> Option { + let source = SystemSource::new(); + cjk_fallback_postscript_names() + .iter() + .find_map(|name| face_covering(postscript_fallback_face(&source, name), ch)) + .or_else(|| { + cjk_fallback_family_names() + .iter() + .find_map(|name| face_covering(family_fallback_face(&source, name), ch)) + }) +} + +/// Return `face` only if it maps `ch` to a real (non-notdef) glyph — the +/// coverage gate for the lazy CJK priority walk in [`cjk_fallback_face_for`]. +fn face_covering(face: Option, ch: char) -> Option { + let face = face?; + (face.font_ref().ok()?.charmap().map(ch) != 0).then_some(face) +} + fn cjk_fallback_postscript_names() -> &'static [&'static str] { #[cfg(target_os = "macos")] { @@ -1613,6 +1649,78 @@ mod tests { ); } + /// Acceptance (a): no CJK font is resolved/mapped at startup. The default + /// stack is primary (Menlo on macOS) + emoji + Nerd + embedded symbols — + /// none of which carry kanji — so a common kanji must NOT be covered by + /// any face already in the stack. It is only resolved later, lazily, via + /// [`cjk_fallback_face_for`]. (Observable externally as vmmap showing no + /// Hiragino/AppleGothic mapped-file regions before CJK is drawn.) + #[test] + fn startup_stack_does_not_cover_cjk() { + let stack = match load_font_stack(&FontConfig::default()) { + Ok(stack) => stack, + Err(e) => { + eprintln!("skipping: no system monospace font available: {e}"); + return; + } + }; + // U+65E5 '日' — a common kanji absent from Menlo/emoji/Nerd/symbols. + let covered = stack.faces().iter().any(|face| { + face.font_ref() + .ok() + .is_some_and(|font| font.charmap().map('日') != 0) + }); + assert!( + !covered, + "the startup font stack must not cover kanji — CJK fonts load lazily, not eagerly" + ); + } + + /// The lazy CJK resolver must (1) return a face that actually covers the + /// codepoint and (2) pick the same face the old eager priority list would + /// have — PostScript names first, then families, each in list order — so + /// Japanese text renders with exactly the same fonts as before. + #[test] + fn cjk_fallback_resolves_in_eager_priority_order() { + let Some(face) = cjk_fallback_face_for('日') else { + eprintln!("skipping: no curated CJK font installed to resolve U+65E5"); + return; + }; + assert!( + face.font_ref() + .expect("resolved face parses") + .charmap() + .map('日') + != 0, + "cjk_fallback_face_for must return a face that covers the codepoint" + ); + + // Independently walk the same priority list; the winner must match. + let source = SystemSource::new(); + let expected = cjk_fallback_postscript_names() + .iter() + .find_map(|name| face_covering(postscript_fallback_face(&source, name), '日')) + .or_else(|| { + cjk_fallback_family_names() + .iter() + .find_map(|name| face_covering(family_fallback_face(&source, name), '日')) + }) + .expect("the same priority walk must also find a covering face"); + + #[cfg(target_os = "macos")] + assert_eq!( + postscript_name_in(&face.bytes, face.index), + postscript_name_in(&expected.bytes, expected.index), + "lazy CJK resolution must select the same face the eager priority list would" + ); + #[cfg(not(target_os = "macos"))] + assert_eq!( + (face.bytes, face.index), + (expected.bytes, expected.index), + "lazy CJK resolution must select the same face the eager priority list would" + ); + } + /// `+list-fonts` relies on this shape: at least one family exists on a /// system with fonts, and the list is strictly sorted (which also proves /// deduplication). diff --git a/crates/noa-font/src/grid.rs b/crates/noa-font/src/grid.rs index 1fe088d..6c66a21 100644 --- a/crates/noa-font/src/grid.rs +++ b/crates/noa-font/src/grid.rs @@ -12,7 +12,9 @@ use unicode_width::UnicodeWidthChar; use crate::atlas::Atlas; use crate::boxdraw::{self, is_builtin_glyph}; -use crate::face::{FontStack, FontStyle, Metrics, cascade_fallback_face, load_font_stack}; +use crate::face::{ + FontStack, FontStyle, Metrics, cascade_fallback_face, cjk_fallback_face_for, load_font_stack, +}; use crate::raster::{GlyphSynthesis, RasterizedGlyph, rasterize_with_variations}; use crate::shape::{self, FaceId, ShapeCell, ShapeRunEntry, ShapedGlyph, StyleKey}; use crate::{FontConfig, FontError, GlyphInfo, GlyphKey}; @@ -153,11 +155,11 @@ pub struct FontGrid { /// row caches hold concrete atlas coordinates, so eviction is a semantic /// invalidation even if the CPU atlas dimensions did not change. atlas_eviction_generation: u64, - /// Codepoints the static stack could not map and the macOS CoreText - /// cascade could not resolve to a real font either (see - /// [`cascade_fallback_face`]). Cached so a genuinely-uncovered glyph is - /// probed once, not on every segmentation pass, and never re-runs the - /// (relatively costly) `CTFontCreateForString` query. + /// Codepoints the static stack could not map and neither the lazy curated + /// CJK list ([`cjk_fallback_face_for`]) nor the macOS CoreText cascade + /// ([`cascade_fallback_face`]) could resolve to a real font either. Cached + /// so a genuinely-uncovered glyph is probed once, not on every + /// segmentation pass, and never re-runs the (relatively costly) resolution. cascade_misses: HashSet, } @@ -343,10 +345,11 @@ impl FontGrid { if let Some(hit) = self.lookup_glyph_in_stack(ch, font_style) { return hit; } - // The curated stack (primary + emoji/Nerd/CJK fallbacks, plus any face - // an earlier cascade hit already pulled in) has no glyph for `ch`. - // Defer to the macOS system cascade once, then retry the lookup. - if self.try_cascade_fallback(ch) + // The curated stack (primary + emoji/Nerd fallbacks, plus any face an + // earlier dynamic-fallback hit already pulled in) has no glyph for + // `ch`. Pull in a fallback lazily (curated CJK list first, then the + // macOS system cascade) once, then retry the lookup. + if self.try_dynamic_fallback(ch) && let Some(hit) = self.lookup_glyph_in_stack(ch, font_style) { return hit; @@ -368,12 +371,15 @@ impl FontGrid { None } - /// Pull a system fallback face covering `ch` into the stack via the macOS - /// CoreText cascade. Returns whether a new face was added (so the caller - /// retries the lookup). Negative results are cached in `cascade_misses` so - /// a genuinely uncovered codepoint probes CoreText only once, not on every - /// segmentation pass. No-op (always `false`) off macOS. - fn try_cascade_fallback(&mut self, ch: char) -> bool { + /// Pull a fallback face covering `ch` into the stack on demand: the curated + /// CJK priority list first ([`cjk_fallback_face_for`] — lazily mapping only + /// the one CJK font that covers `ch`, in the same priority order the eager + /// stack used), then the macOS CoreText system cascade + /// ([`cascade_fallback_face`]) for anything CJK doesn't cover. Returns + /// whether a new face was added (so the caller retries the lookup). + /// Negative results are cached in `cascade_misses` so a genuinely uncovered + /// codepoint is resolved once, not on every segmentation pass. + fn try_dynamic_fallback(&mut self, ch: char) -> bool { if self.cascade_misses.contains(&ch) { return false; } @@ -383,9 +389,9 @@ impl FontGrid { self.cascade_misses.insert(ch); return false; } - match cascade_fallback_face(ch) { - // `cascade_fallback_face` guarantees the returned face maps `ch`, - // so the caller's retry is guaranteed to find it. + // Both resolvers guarantee the returned face maps `ch`, so the caller's + // retry is guaranteed to find it. + match cjk_fallback_face_for(ch).or_else(|| cascade_fallback_face(ch)) { Some(face) => { self.font_stack.push_dynamic_fallback(face); true @@ -1006,6 +1012,79 @@ mod tests { ); } + /// A CJK codepoint absent from the (CJK-free) startup stack is resolved + /// lazily: the first lookup pulls in exactly one fallback face, and the + /// second is served from the now-populated stack without growing it again + /// or re-running resolution. + #[test] + fn cjk_glyph_is_resolved_lazily_then_cached() { + let mut grid = match FontGrid::new(14.0, FontConfig::default()) { + Ok(g) => g, + Err(e) => { + eprintln!("skipping: no system monospace font available: {e}"); + return; + } + }; + // Precondition for a meaningful test: the startup stack must not + // already cover the kanji (otherwise there is nothing to lazily pull). + if grid + .lookup_glyph_in_stack('日', FontStyle::Regular) + .is_some() + { + eprintln!("skipping: this environment's startup stack already covers kanji"); + return; + } + + let faces_before = grid.font_stack.faces().len(); + let (face_index, glyph_id) = grid.resolve_glyph('日'); + if glyph_id == 0 { + eprintln!("skipping: no CJK-capable font installed to resolve U+65E5"); + return; + } + assert_eq!( + grid.font_stack.faces().len(), + faces_before + 1, + "a CJK miss must lazily pull in exactly one fallback face" + ); + assert!( + grid.font_stack.is_fallback_face(face_index), + "the kanji must resolve to a dynamically-added fallback face, not the primary" + ); + + // Second lookup: same face + glyph, and the stack does not grow again + // (served from the populated stack, no re-resolution). + let (face_index2, glyph_id2) = grid.resolve_glyph('日'); + assert_eq!((face_index, glyph_id), (face_index2, glyph_id2)); + assert_eq!( + grid.font_stack.faces().len(), + faces_before + 1, + "a cached CJK lookup must not pull in another fallback face" + ); + } + + /// The non-CJK path is unaffected: plain ASCII stays on the primary face + /// (index 0) and never pulls a fallback into the stack. + #[test] + fn ascii_resolution_stays_on_primary_and_adds_no_fallback() { + let mut grid = match FontGrid::new(14.0, FontConfig::default()) { + Ok(g) => g, + Err(e) => { + eprintln!("skipping: no system monospace font available: {e}"); + return; + } + }; + let faces_before = grid.font_stack.faces().len(); + + let (face_index, glyph_id) = grid.resolve_glyph('A'); + assert_eq!(face_index, 0, "ASCII 'A' must resolve to the primary face"); + assert_ne!(glyph_id, 0, "the primary face must cover ASCII 'A'"); + assert_eq!( + grid.font_stack.faces().len(), + faces_before, + "resolving ASCII must not pull any fallback face into the stack" + ); + } + #[test] fn japanese_glyph_uses_fallback_face_when_available() { let mut grid = match FontGrid::new(14.0, FontConfig::default()) {