Skip to content

perf(font): lazy-load CJK fallback fonts to cut ~106MB idle RSS#20

Merged
simota merged 1 commit into
mainfrom
perf/lazy-cjk-font-fallback
Jul 16, 2026
Merged

perf(font): lazy-load CJK fallback fonts to cut ~106MB idle RSS#20
simota merged 1 commit into
mainfrom
perf/lazy-cjk-font-fallback

Conversation

@simota

@simota simota commented Jul 16, 2026

Copy link
Copy Markdown
Owner

Summary

noa's idle RSS ran ~100MB above Ghostty while phys_footprint was nearly equal. Attribution via footprint/vmmap showed the gap was almost entirely clean file-backed font pages: 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 ≈ 104MB) — resident even when no CJK glyph is ever drawn. Ghostty lazy-loads the same fonts via CoreText and keeps ~0.4MB resident.

This PR drops the eager CJK loop from load_font_stack_with_primary and resolves CJK on first stack miss instead. cjk_fallback_face_for walks the exact priority order the eager list used (PostScript names, then families), gated by cmap coverage, ahead of the generic CoreText cascade — so which font wins for a given codepoint is unchanged. Resolution goes through the existing no-slurp CoreText→mmap path, so even after CJK is drawn only touched glyph pages become resident.

before (eager) after (lazy)
idle RSS 239 MB 133 MB
idle CJK font resident 104 MB (whole files) 0 MB (unmapped)
Hiragino W3 resident after drawing kanji 30 MB (always) 0.5 MB (touched pages)
phys_footprint 100 MB 92 MB (as expected — these pages never counted)

Test plan

  • 5 new unit tests: startup stack covers no kanji; lazy resolution picks the same face as the old eager priority walk; a CJK miss pulls in exactly one face then serves from cache; ASCII path unaffected; existing Japanese rasterization tests pass through the lazy path.
  • cargo test --workspace green (noa-font 65, noa-app 949, ...), cargo clippy --workspace clean, cargo fmt --check clean.
  • E2E in the bundled Noa.app: typed 「日本語テスト漢字かなカナ」, screenshot-verified correct glyphs (no tofu), vmmap confirmed no CJK font mapped at idle and only partial residency after drawing.

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.
@simota
simota merged commit ca54a34 into main Jul 16, 2026
1 check passed
@simota
simota deleted the perf/lazy-cjk-font-fallback branch July 16, 2026 12:52
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.

1 participant