Skip to content

fix(translate): stop the sidecar's RAM climbing with every translated page - #136

Merged
TranBaDat2607 merged 5 commits into
mainfrom
fix/translation-memory-drift
Oct 23, 2026
Merged

TranBaDat2607 merged 5 commits into
mainfrom
fix/translation-memory-drift

Conversation

@TranBaDat2607

Copy link
Copy Markdown
Owner

Why

During a translation the sidecar's RAM climbed with every page, about +1.25 MB a page after warm-up on a 346-page book. That held even after #135 fixed the fonts. Measuring the real pipeline after each page found two causes:

  1. A PyMuPDF refcount leak (< 1.27.2). _extra.JM_get_annot_xref_list leaks a list per call, and insert_pdf copies every link through it (two lists per link).
    • rebuild_rolling_pdf re-inserts the whole document after every page. On the book that leaked ~2,120 lists per rebuild while it was untranslated, and ~1,690 live objects a page averaged over a run.
    • That is O(pages²) for a whole document, and gc can't see it.
  2. glibc keeping freed heap. A final malloc_trim handed back ~410 MB. A forced gc.collect() freed nothing: the memory was already free, just never returned.

What changes

commit what
fix(chat): join line-break hyphens without PyMuPDF's flag PyMuPDF 1.27 silently ignores TEXT_DEHYPHENATE. Without a replacement, 1,129 words of the book were indexed as halves (trans- action). ingest._join_broken_words reproduces the flag's rule exactly.
fix(translate): require PyMuPDF>=1.27.2,<1.28 Adds a regression test, and re-checks the GIL property the shared fonts rely on, on 1.27.2.3.
fix(translate): malloc_trim(0) after every finished page utils/memory.py, run off the event loop, after the rebuild and after chunk_ready. A no-op on Windows, macOS and musl. Ported from the unmerged fix/release-freed-memory, whose root-cause prose was wrong (that 7 GB OOM was the fonts).
fix(chat): the Unicode hyphens too The flag also joined U+00AD, U+2010 and U+2011. Found in review; the benchmark corpus has none of them.
docs(translate): reconcile the figures Each number in the notes now names what it was measured on.

No chat index is rebuilt. On PyMuPDF 1.25.5 the ingestor's chunks are byte-identical to main on all 7 text documents of the RAG benchmark, so CHUNKER_VERSION is unchanged. A "smarter" join that kept compounds apart (Bose-/Einstein) was measured and dropped: it lost 2 questions.

Results

129 text pages of the book, Argos, 2 pages in flight, measured after warm-up:

RSS slope p47–129 RSS at end freed by a final trim live objects p47→p129
main (PyMuPDF 1.25.5) +1.25 MB/page 2,118 MB 408 MB +123,761
PyMuPDF 1.27.2.3 only +0.18 MB/page 2,066 MB 419 MB −8,080
this branch −0.19 MB/page 1,656 MB 2 MB −4,786

Speed was compared A-B-B-A, sequentially, 46 pages each:

run 1 run 2
main 4.33 s/page 4.57 s/page
this branch 4.47 s/page 4.38 s/page

That's no difference beyond main's own run-to-run spread. The trim costs 8–22 ms a page, and the 129-page run on this branch did 4.50 s/page.

Behaviour changes worth knowing

  • Chat retrieval (retrieval-only, 131 questions), hit@1:

    main, 1.25.5 1.27, no join 1.27 + this branch
    0.857 0.838 0.848 (McNemar p = 1.0)

    The one question still lost (p10, ResNet) comes from 1.27's find_tables. It now reads ResNet's architecture figure as half-empty two-column tables (121 → 133 chunks on that paper). Tightening the ingestor's mostly-empty-grid guard could be a follow-up; the rag-quality gate is 0.80.

  • find_tables prints a one-line pymupdf_layout notice to stdout once per process; it goes to shell.log.

  • Translation output: it differs from main on a few pages' wording, but no more than two runs of main differ from each other. Argos's batching depends on timing.

Test plan

  • pytest tests: 1440 passed (1425 before, plus 5 trim, 1 leak and 9 dehyphenation cases); ruff clean.
  • Every new test was seen red for the reason it targets:
    • the leak test failed on 1.25.5 with growth 1,279 (0 on 1.27.2.3);
    • the parity cases failed on the abandoned rule and on the ASCII-only rule;
    • the trim tests failed against two stubs.
  • Expected values come from the reference implementation (main's ingestor on 1.25.5), never from the new code.
  • RAG retrieval benchmark: pm1255, pm1272, pm1272-dehyph, pm1272-mupdfrule, compared with compare.py.
  • 129-page memory run through the patched processor.py. The call site has no unit test, so this is the check.
  • Frozen sidecar, built locally with ./build-sidecar.sh (Linux): pytest -m smoke 21 passed, frozen half included.
  • Through the frozen exe over HTTP:
    • Argos translate of pages 35–40: done in 30 s, 0 failed paragraphs, Vietnamese output.
    • Cancel on a 55-page run: cancelled 0.1 s after the request.
    • Chat index of resnet.pdf: done, 133 chunks.
  • frozen-sidecar workflow dispatched (Windows + Ubuntu): https://github.com/TranBaDat2607/PDFusion/actions/runs/39939620038
  • Not measured: Windows memory (Measure sidecar memory across a long translation on Windows #134). The trim is a no-op there; the PyMuPDF fix applies everywhere.

Found along the way, not fixed here

  • An intermittent startup import race. The first chat index, requested 0.7 s after READY, failed once inside the frozen exe with cannot import name 'NDArray' from partially initialized module 'numpy._typing'. ChromaDB's import ran while the engine-warm thread was still importing numpy through BabelDOC. Two reruns, one immediately and one after 60 s, passed. Probably on main as well, since neither import path changes here, but not verified there.
  • Blank pages still fail a translation on main ("contains no paragraphs"). The fix sits on the unmerged fix/skip-textless-pages.
  • BabelDOC's concurrent PDF-save forks still trip filelock's at-fork check, and BabelDOC retries them.

🤖 Generated with Claude Code

TranBaDat2607 and others added 5 commits October 23, 2026 16:52
PyMuPDF 1.27, which the translation memory fix needs, ignores
TEXT_DEHYPHENATE in every output mode. Without a replacement, chat
indexed 1,129 words of a 346-page book as halves ("trans- action") that
neither keyword nor semantic search matches.

ingest._join_broken_words reproduces the flag's rule exactly: a line
ending in "-" loses it and runs into the next, unless the line is only
the dash. On PyMuPDF 1.25.5 the ingestor's chunks are byte-identical
before and after this change on all seven text documents of the RAG
benchmark, so CHUNKER_VERSION stays and no index is rebuilt.

A variant that spared compounds (joining only lowercase continuations)
was measured and lost two questions on BERT, whose tables use "-" for
empty cells; the parity test's expected text is what 1.25.5 indexed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Before 1.27.2, PyMuPDF's C++ helper behind annot_xrefs leaks a list on
every call, and insert_pdf copies every page's links through it.
rebuild_rolling_pdf re-inserts the whole document after each page, so a
translation leaked ~2,120 lists (~150 KB) per page on a 346-page book:
O(pages^2) for a whole document, and invisible to gc, since each list
carries one extra reference and no referrer.

Measured over 129 pages of that book after a ~40-page warm-up: +1.25
MB/page on 1.25.5, +0.18 on 1.27.2.3, live objects flat. 1.26.7 still
leaks; 1.27.2.3 and 1.28.2 don't. <1.28 because 1.28 deprecates the
`fitz` name every module imports. The GIL property the shared fonts
rely on was re-checked on 1.27.2.3 (no PyEval_SaveThread import).

The new test rebuilds a linked document's rolling PDF 20 times: 1,279
objects stay alive on 1.25.5; on 1.27.2.3 fewer than the
threshold of one per rebuild. Pages without links
never leaked, which is why the older rebuild tests could not catch it.

1.27 also ignores TEXT_DEHYPHENATE (handled in the previous commit) and
its find_tables reads ResNet's architecture figure as tables; together
with the dehyphenation fix, retrieval-only hit@1 is 0.848 against 0.857
on 1.25.5 (one question, McNemar p = 1.0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
glibc keeps what Python frees inside its arenas and returns memory on
its own only from the top of a heap. On PyMuPDF 1.27, a 129-page Argos
run ended ~420 MB above what malloc_trim brings it back to; a forced
gc.collect() freed next to nothing, since the memory was already free.

The consumer loop in _process_with_babeldoc now ends each finished page
with utils.memory.release_freed_memory, off the event loop: malloc_trim(0)
on glibc, a no-op on Windows, macOS and musl. It runs after the rolling
rebuild, so that document is freed too, and after chunk_ready, so the
viewer's swap never waits on it. No gc.collect(): ~277 ms a page for
nothing.

Measured on this branch over the same 129 pages, run alone: -0.19
MB/page after warm-up (main: +1.25), 1,656 MB at the end (main: 2,118),
a final forced trim frees 2 MB (main: 408), 4.50 s/page. The trim costs
8-22 ms a page.

The helper and its tests are ported from fix/release-freed-memory
(dc4c1be), whose prose blamed glibc for the 7 GB out-of-memory that was
really BabelDOC's fonts (fixed by font_cache.py); the numbers here are
re-measured. Windows is still to be measured (#134).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The replacement for the flag only knew "-". Checked one by one on
PyMuPDF 1.25.5, the flag also took U+00AD (soft hyphen), U+2010 and
U+2011 for a line-break hyphen, and kept a lone U+2010 line the way it
keeps a lone "-". Word processors export these; the benchmark corpus
has none, which is why the chunk-for-chunk parity check missed them.
Without this, "trans­" / "action" was indexed with the soft hyphen in
the middle (NFKC keeps it).

The U+2010/U+2011 cases join the parity table, their expected text read
off main's ingestor on 1.25.5. U+00AD is tested on the helper itself: a
base-14 font reads it back as "-" and PyMuPDF's built-in fonts drop it,
so no portable PDF fixture can produce it; the expected text is what
1.25.5 indexed for a page set in DejaVu Sans. Chunks of all seven
benchmark documents are still byte-identical to main on 1.25.5.

Also says in data-and-chat.md why CHUNKER_VERSION stays although
1.27's find_tables changes some chunks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review found the same quantities quoted differently. Each now names
what it was measured on:

- leaked objects: ~2,120 lists per rebuild while the book is still
  untranslated, ~1,690 live objects a page averaged over the 129-page
  run (translated pages carry no links). Two lists per link, not one:
  the regression test leaves 1,278 objects after 20 rebuilds on 1.25.5,
  against 2 lists x 32 links (40, less the 8 on its two translated
  pages) x 20 = 1,280, and 0 on 1.27.2.3.
- freed heap: ~410 MB at the end of the main run, ~420 MB on 1.27.
- the slope is far larger than the lists' own ~150 KB; the notes now say
  the causal link is the before/after measurement of the bump, and that
  how each list pins memory around it is not measured.

Also turns the two causes into prose, so Markdown no longer runs them
into the font section's list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@TranBaDat2607
TranBaDat2607 merged commit f422e89 into main Oct 23, 2026
13 checks passed
@TranBaDat2607 TranBaDat2607 mentioned this pull request Oct 23, 2026
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