Skip to content

fix(translate): load BabelDOC's fonts once per process - #135

Merged
TranBaDat2607 merged 2 commits into
mainfrom
fix/shared-babeldoc-fonts
Oct 23, 2026
Merged

TranBaDat2607 merged 2 commits into
mainfrom
fix/shared-babeldoc-fonts

Conversation

@TranBaDat2607

Copy link
Copy Markdown
Owner

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 FontMapper in 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) into pymupdf.Font objects.

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 calls gc.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() 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. Any other version logs a warning and keeps its own constructor.
    • routes/translation.py:_load_engine installs it.
  • Comment cleanup (first commit). Deletes comments the code contradicts, among them the "~9 GB per sub-job" figure: BabelDOC's "Peak memory usage" line measures the whole process. It also removes the dead _MAX_PARALLEL_CHUNKS constant. Each claim is listed in the commit message.
  • Docs. New section "Each font is loaded once per process" in docs/architecture/translation-pipeline.md, plus a CLAUDE.md invariant.

Tests

tests/test_font_cache.py has 17 tests, written test-first by a separate agent that never saw the implementation, and reviewed independently. They cover:

  • sharing across mappers, stages and languages;
  • one load per file;
  • per-instance state;
  • equivalence with BabelDOC's own constructor, per mapper and per font attribute;
  • memos wrapped once;
  • concurrent construction;
  • failed loads not cached;
  • fonts outliving their mappers;
  • the version guard;
  • the job-path wiring.

Mutation-checked against the real module: a lock-free cache and a load-then-setdefault cache 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.

Run Before After
Argos, 4 pages 0.26 → 6.5 GB, aborted ~1.7 GB after 8 pages
LLM path (echo stub), 6 pages in flight >6.5 GB before the first page finished —
Whole book, 301 text pages OOM-killed at ~12 GB, page 238 23 min, peak 2.38 GB
  • Output. Page 25 is identical before and after on both paths: every word, its position and its fonts. No PIPELINE_VERSION bump needed.
  • Residual drift. Memory still rises ~1.7 MB per page over the book, most likely from the rolling-PDF rebuild and per-page source-font garbage. A thousands-page book will peak above 2.4 GB; not yet measured.

Not exercised

  • the frozen PyInstaller build;
  • Windows;
  • a run through pnpm tauri dev.

Open questions

  • babeldoc is pinned to a range (>=0.5.20,<0.6.0). The first new 0.5.x release turns CI red via test_the_installed_babeldoc_is_one_the_copy_was_checked_against, and a fresh build would ship unpatched. Pin babeldoc==0.5.24, or keep the tripwire?
  • Expect merge conflicts with the unmerged fix/skip-textless-pages and fix/release-freed-memory branches, in processor.py, CLAUDE.md and the pipeline doc. On main, a blank page still fails the job ("contains no paragraphs"); the first of those branches fixes that.

🤖 Generated with Claude Code

TranBaDat2607 and others added 2 commits October 23, 2026 13:57
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>
@TranBaDat2607
TranBaDat2607 merged commit 0674b31 into main Oct 23, 2026
6 checks passed
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