Repository navigation
fix(translate): load BabelDOC's fonts once per process - #135
Merged
Merged
Conversation
Each claim below was checked against the code or measured, and is false: - "Each in-flight BabelDOC sub-job peaks at ~9 GB" and "four chunks = ~36 GB": BabelDOC's "Peak memory usage" line is the whole sidecar plus its children, not one sub-job. The 120 s save timeouts blamed on swap are, per CLAUDE.md, the freeze_support bug. - "~150-300 MB of intermediate state" per sub-job: each page loaded ~1.6 GB of fonts. - `_MAX_PARALLEL_CHUNKS` "may be in flight at once": it was referenced nowhere; `_effective_parallel_chunks` decides. Removed with its comment. - "batches of 4": `batch_size` is 16. - "CTranslate2 serializes the decode": `inter_threads` is 2 on four or more cores. - "process-lifetime translation cache": it is the on-disk paragraph cache. - "≈16 paragraphs in flight": Argos runs 2 sub-jobs of 4 workers, so 8. - `enhance_compatibility` "forces extra typesetting passes": in babeldoc 0.5.24 it only turns on three flags that are already on or moot here. - Glossary extraction "invokes the translator" on LLM paths: BabelDOC extracts terms only through `do_llm_translate`, which no PDFusion translator has. - "ILTranslator twice on the LLM path", "272 checks per page (238 on Argos)": the measured counts are 7 FontMappers a page on the LLM path and 6 on Argos. - Argos "gains nothing from extra parallelism" / "saturates at low parallelism": 4 chunks in flight ran 31 pages 13% faster than 2. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A 346-page book translated with Argos was killed by the kernel's OOM killer 238 pages in, with the sidecar at ~12 GB. The LLM path has the same problem, with three times the pages in flight. BabelDOC 0.5.24 builds a FontMapper in every pipeline stage, 6 per page on Argos and 7 on the LLM path. Each one reads all 34 embedding fonts (266 MB) into pymupdf.Font objects. Its constructor wraps bound methods in lru_cache, so every mapper and every font sits in a reference cycle that only a full GC frees, and full collections follow object counts, which font buffers barely move. Replayed through the real pipeline, 4 pages took the process from 0.26 GB to 6.5 GB with no full collection in between. font_cache.install() replaces FontMapper.__init__ with a copy of BabelDOC's whose font loop takes each font from a process-wide cache, decorated once. It only patches a babeldoc in VERIFIED_BABELDOC: on any other version it logs a warning and leaves the constructor alone, which costs memory instead of breaking every stage. routes/translation.py _load_engine installs it before BabelDOC builds anything. Sharing is safe because no stage writes to a mapper's fonts after construction, and PyMuPDF never releases the GIL: neither _mupdf.so nor _extra.so imports PyEval_SaveThread. Measured on the same book afterwards: its 301 pages with text ran in 23 minutes with the sidecar peaking at 2.38 GB. Page 25's output is identical before and after, word for word and font for font, on both paths. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7 of 9 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes the out-of-memory kill when translating a long document. It hits Argos and LLM translation alike.
Cause
BabelDOC 0.5.24 builds a
FontMapperin every pipeline stage: 6 per page on Argos, 7 on the LLM path, counted on real runs. Each one loads all 34 embedding fonts (266 MB) intopymupdf.Fontobjects.Its constructor wraps bound methods in
lru_cache, so every mapper and font sits in a reference cycle that only a full GC frees. Nothing callsgc.collect(), and full collections follow object counts, not bytes.PDFusion runs one BabelDOC job per page, so on a 346-page book the sidecar reached ~12 GB and the kernel's OOM killer killed it 238 pages in.
Change
processors/font_cache.py.install()replacesFontMapper.__init__with a copy of BabelDOC's whose font loop takes each font from a process-wide cache, decorated once.VERIFIED_BABELDOC. Any other version logs a warning and keeps its own constructor.routes/translation.py:_load_engineinstalls it._MAX_PARALLEL_CHUNKSconstant. Each claim is listed in the commit message.docs/architecture/translation-pipeline.md, plus a CLAUDE.md invariant.Tests
tests/test_font_cache.pyhas 17 tests, written test-first by a separate agent that never saw the implementation, and reviewed independently. They cover:Mutation-checked against the real module: a lock-free cache and a load-then-
setdefaultcache each fail exactly the test meant for them. Full suite: 1425 passed, 8 skipped; ruff clean.Measured
All on Linux, Argos and an echo stub, with no LLM calls.
PIPELINE_VERSIONbump needed.Not exercised
pnpm tauri dev.Open questions
>=0.5.20,<0.6.0). The first new 0.5.x release turns CI red viatest_the_installed_babeldoc_is_one_the_copy_was_checked_against, and a fresh build would ship unpatched. Pinbabeldoc==0.5.24, or keep the tripwire?fix/skip-textless-pagesandfix/release-freed-memorybranches, inprocessor.py, CLAUDE.md and the pipeline doc. Onmain, a blank page still fails the job ("contains no paragraphs"); the first of those branches fixes that.🤖 Generated with Claude Code