Skip to content

fix(ocr): stop task="parse" rejecting ordinary multi-page PDFs - #5308

Merged
OliverBryant merged 6 commits into
xorbitsai:mainfrom
OliverBryant:fix/parse-size-budget
Aug 12, 2026
Merged

fix(ocr): stop task="parse" rejecting ordinary multi-page PDFs#5308
OliverBryant merged 6 commits into
xorbitsai:mainfrom
OliverBryant:fix/parse-size-budget

Conversation

@OliverBryant

Copy link
Copy Markdown
Collaborator

What

Fixes the size-budget defect in task="parse" reported in #5307: an ordinary 31-page A4 text PDF (393 KB) was rejected at every zoomin from 1 to 6, and the 400 advised lowering zoomin, which makes the computed budget larger.

Why it happened

Two independent causes, both confirmed against deepdoc-lib 0.2.2 rather than assumed:

1. Every page was budgeted for a conditional retry. deepdoc/parser/pdf_parser.py re-renders at zoomin * 3 only when len(self.boxes) == 0. boxes accumulates across every page, so the escalation fires only if not one page in the whole document produced a single box — a scanned or broken PDF, not a text document. An A4 page renders to 4.5 MP at zoomin=3 but was charged 45 MP, putting the whole-document ceiling at ~22 A4 pages where actual usage allows ~221.

2. The retry ladder is not monotonic in zoomin. The guard tests the pre-multiplication value:

if len(self.boxes) == 0 and zoomin < 9:
    self.__images__(fnm, zoomin * 3, page_from, page_to, callback)

so 2 → 6 → 18 and 6 → 18, while 3 → 9. "Lower zoomin" was therefore not sound advice. The server's worst_case_parse_zoom models this faithfully — the arithmetic was never the bug.

What changed

  • The whole-document budget is enforced at the requested scale, not the retry scale. This is what makes ordinary documents parseable.
  • A separate, looser ceiling bounds the escalated re-render (MAX_PDF_PARSE_RETRY_TOTAL_PIXELS, 3 G px). Without it, budgeting only at the requested scale would have left a 32–130 GB hole when the retry does fire — so this is not simply raising the ceiling. The escalated render replaces the first one (the retry re-enters __images__, rebinding self.page_images), which is why the two ceilings differ rather than summing.
  • The per-page ceiling stays at the worst-case scale, so a single outsized MediaBox is still rejected on the strength of a retry that may fire.
  • Rejections name a zoomin that actually fits, searching the whole permitted range rather than only downwards — with a non-monotonic ladder, a higher zoom can be the one that fits. When nothing fits, it advises splitting instead.
  • Both whole-document ceilings are configurable via XINFERENCE_MAX_PDF_PARSE_TOTAL_PIXELS and XINFERENCE_MAX_PDF_PARSE_RETRY_TOTAL_PIXELS.

Memory safety

The intent is preserved rather than traded away. An accepted document is bounded at the requested scale (~4 GB of page images at 1 G px), and if the retry fires the escalated document is bounded at ~12 GB — uniformly, regardless of zoomin, versus 32–130 GB under requested-scale budgeting alone. The binding constraint on long documents is now the retry ceiling at ~73 A4 pages, which is honest: a 200-page A4 document really would need ~32 GB if it escalated.

Before / after on the reported document

31-page A4, 595x842 pt:

zoomin before after
1 400 accepted
2 400 (budgeted at 18x) 400 → "retry with zoomin 4"
3 (default) 400 accepted
4 400 accepted
5 400 400 → "retry with zoomin 4"
6 400 400 → "retry with zoomin 4"

Every rejection now names zoomin 4 — which genuinely works, and is higher than the old advice would have sent the caller.

Upstream

The non-monotonic ladder belongs in deepdoc-lib, not here. Clamping it to min(zoomin * 3, 9) would cap the real allocation and make the scale monotonic, at which point the retry ceiling here could be tightened. I have deliberately not clamped it server-side: that would model an escalation bound the parser does not actually honour. Worth filing upstream separately; this PR handles the server-side half.

Tests

Added to xinference/api/tests/test_ocr_pdf.py, including the two cases that would have caught this: a realistic 31-page A4 document being accepted at the default zoom, and the property that a rejection always carries a true way forward (parametrized over 1/5/31/100/200 pages — either a named zoom that itself validates, or advice to split). Also covers the upward-search gap, the retry-budget invariant, and env-var overrides.

397 passed, 17 skipped     # xinference/api/tests/ + xinference/model/image/ocr/tests/

pre-commit run --files ... passes on all changed files (black, ruff, isort, mypy, codespell).

Docs updated in doc/source/models/model_abilities/image.rst, including an explicit warning that lowering zoomin can make the budget larger.

Ref #5307

A 31-page A4 text PDF was rejected at every zoomin from 1 to 6, and the
400 advised lowering `zoomin`, which made the computed budget larger.

Two independent causes:

* Every page was budgeted for the 9x retry, which deepdoc only performs
  when the whole document yields no OCR boxes at all. An A4 page renders
  to 4.5 MP at zoomin 3 but was charged 45 MP, putting the whole-document
  ceiling at ~22 A4 pages.
* The retry ladder is not monotonic in zoomin. deepdoc-lib tests
  `zoomin < 9` before multiplying, so 2 and 6 both escalate to 18x while
  3 stops at 9x. "Lower `zoomin`" was therefore unsound advice.

The whole-document budget is now enforced at the requested scale, with a
separate, looser ceiling bounding what the escalated re-render would cost
if the retry does fire. The per-page ceiling stays at the worst-case
scale, so a single outsized MediaBox is still rejected. That keeps the
memory-safety intent: an escalated document is held to ~12 GB of page
images instead of the 32-130 GB the requested-scale budget alone would
have allowed.

Rejections now name a zoomin that actually fits, searching the whole
permitted range rather than only downwards, and fall back to advising a
split when no zoom works. Both whole-document ceilings are configurable
via XINFERENCE_MAX_PDF_PARSE_TOTAL_PIXELS and
XINFERENCE_MAX_PDF_PARSE_RETRY_TOTAL_PIXELS.

The 31-page document now parses at the default zoomin 3, and the
rejections at 2, 5 and 6 all point at zoomin 4.

Ref xorbitsai#5307
@XprobeBot XprobeBot added the bug Something isn't working label Aug 12, 2026
@XprobeBot XprobeBot added this to the v3.x milestone Aug 12, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the PDF parsing size limit logic to prevent false-positive rejections of standard documents. It splits the whole-document budget into a requested-scale ceiling and a looser retry-scale ceiling, allows configuring these ceilings via environment variables, and improves error messages by suggesting a specific fitting zoom factor. The review feedback correctly identifies a critical runtime bug where formatting integers with the :g format specifier in f-strings will raise a ValueError and crash the application.

Comment thread xinference/api/pdf_ocr.py Outdated
Comment thread xinference/api/pdf_ocr.py Outdated
Comment thread xinference/api/pdf_ocr.py Outdated
@OliverBryant

Copy link
Copy Markdown
Collaborator Author

End-to-end verification on GPU hardware

Verified on an RTX 3090 Ti box with real DeepDoc inference, not just the validation layer. Both runs used the same 31-page A4 text PDF (595.28 x 841.89 pt), the same DeepDoc model from ModelScope, and deepdoc-lib 0.2.2.

Before (unpatched, at e45894915)

All six zoomin values rejected, exactly as reported:

zoomin reported total worst case
1 1,037,233,622 9x
2 1,082,382,462 18x
3 1,037,233,622 9x
4 1,042,280,369 12x
5 1,002,251,168 15x
6 1,082,382,462 18x

Every message ended in lower `zoomin` or split the document, while zoomin=2 was budgeted at 18x — four times zoomin=3.

(The totals differ from the issue's by ~0.02% only because this document's MediaBox is 595.28 pt rather than an exact 595.)

After (this PR, same box, same document)

zoomin result
1 parsed, 1054 elements, 42.0s
2 400 → retry with `zoomin` 4
3 (default) parsed, 1054 elements, 32.1s
4 parsed, 1054 elements, 40.7s
5 400 → retry with `zoomin` 4
6 400 → retry with `zoomin` 4

Element counts are identical across all three accepted zooms, so the budget change does not alter parse output.

The advice is actionable

Followed the message mechanically — request at zoomin=6, take the value it names, retry:

zoomin=6 -> 400 "...exceeding the retry limit of 3000000000; retry with `zoomin` 4"
zoomin=4 -> OK  task=parse  elements=1054
            types: {'text': 1023, 'title': 31}
            first text: "Chapter 1: Evaluation of Document Parsing"

1023 text + 31 title matches the document's structure (one heading per page), and the text matches the source. The recommended zoom is higher than the request — advice the old "lower zoomin" message could not have produced, and which lowering to 5 would not have found either (15x, also rejected).

Unrelated environment note

Launching DeepDoc on a fresh venv failed with ImportError: cannot import name 'is_offline_mode' from 'huggingface_hub' — the venv resolves huggingface_hub==0.36.2 while the launching environment has 1.26.1, and the venv is rebuilt back to 0.36.2 on each restart. Pinning the venv to 1.26.1 fixes it. This is unrelated to this PR (it reproduces on unpatched e45894915 too) and looks like the same class of issue as the deepdoc-lib hub pin from #5230; worth tracking separately.

Comment thread xinference/api/pdf_ocr.py Outdated
Comment thread xinference/api/pdf_ocr.py Outdated
Review found the retry budget rests on a condition deepdoc-lib 0.2.2
cannot reach. `__ocr` appends to `self.boxes` on every page --
`append([])` when a page yields nothing, `append(bxs)` otherwise -- so
after the OCR pass `len(self.boxes)` equals the page count. The
`len(self.boxes) == 0` guard the 9x re-render sits behind is therefore
only true when there were no pages to render at all, i.e. the load
failed, in which case there is nothing to re-render.

Budgeting the whole document for that pass rejected a 74-page A4 PDF
whose real render is ~334 M pixels. Removing it also resolves the peak
undercount raised alongside it: with no aggregate retry budget there is
no figure to undercount.

The per-page ceiling is still checked at the worst-case scale. That is
cheap insurance against a single outsized MediaBox and does not depend
on this reasoning holding for every deepdoc-lib version -- and it is
what keeps the budget non-monotonic in zoomin, so the recommendation
search that motivated this PR is still load-bearing.

Also drops the `:g` format specifiers on integer zoom values; they are
meaningless for ints and would render large values in scientific
notation.

The 31-page A4 document from xorbitsai#5307 now parses at every zoomin from 1
to 6.
@OliverBryant

Copy link
Copy Markdown
Collaborator Author

Updated following review — the retry budget is gone

The review established that the 9x re-render is unreachable, which invalidated the premise the retry budget rested on. eae6c3a5a removes it.

__ocr appends to self.boxes on every page — append([]) when a page detects nothing, append(bxs) otherwise — so len(self.boxes) equals the page count after the OCR pass, and the len(self.boxes) == 0 guard the re-render sits behind can only be true when there were no pages to render at all. My original claim ("boxes accumulates, so len == 0 means nothing was found anywhere") came from grepping one of the two append sites, and was wrong.

This also retroactively explains an observation I had recorded but not chased: across six end-to-end runs on real hardware, a 9x re-render never occurred.

What changed since the first review

before review now
Whole-document budget requested scale + 3 G px retry ceiling requested scale only
31-page A4 accepted at 1, 3, 4 accepted at every zoomin 1–6
74-page A4 @ zoomin 3 rejected accepted
200-page A4 @ zoomin 3 rejected accepted
MAX_PDF_PARSE_RETRY_TOTAL_PIXELS new constant + env var removed

The per-page ceiling still runs at the worst-case scale. It is cheap, it guards a single outsized MediaBox, and it does not depend on the reachability argument holding for every deepdoc-lib version. It is also what keeps the budget non-monotonic in zoomin — a 1000x1000 pt page fits at 1, 3 and 4 but not at 2 — so the recommendation logic remains load-bearing and is still covered by tests.

Also dropped the :g format specifiers on integer zoom values.

Upstream

The predicate still looks wrong upstream: len(self.boxes) == 0 was presumably intended as something like not any(self.boxes), which would make the recovery path actually reachable for a document that renders but reads as blank. I have deliberately not pinned or worked around it here, since this PR no longer depends on the retry behaving one way or the other. Worth raising against deepdoc-lib separately.

Verification

396 passed, 17 skipped     # xinference/api/tests/ + xinference/model/image/ocr/tests/

pre-commit (black, ruff, isort, mypy, codespell) passes on all changed files.

The GPU end-to-end results posted earlier were produced with the retry budget still in place; they remain valid as a before/after for the original bug, and the change since only widens what is accepted, with the accepted set now a superset of what was verified there.

The docstrings still described the 9x re-render as something that fires
when a page yields no boxes, and justified the per-page ceiling by a
retry 'that may still fire'. Both predate the review finding that the
re-render is unreachable. The per-page ceiling is kept as insurance
against that reasoning changing, not because the retry is expected.
Comment thread xinference/api/pdf_ocr.py Outdated
The document-wide budget sits at the requested scale on the strength of
the 9x retry being unreachable in deepdoc-lib 0.2.2, but the dependency
is `~=0.2.2` and accepts later 0.2.x, so that could change.

What bounds the damage if it does is not that reasoning but the per-page
ceiling, which is still enforced at the worst-case scale, times the page
ceiling: no page may peak above 200 MP even after escalating and at most
200 pages are accepted, so an escalated document cannot exceed 40 G px.
Searching every page geometry and zoom both budgets admit, the true
maximum is 39.96 G px, from 200 pages of 200x11100 pt at zoomin 1.

Pinned as a test so raising either limit has to face what it does to the
escalated worst case.
The previous reasoning was that the per-page ceiling times the page
ceiling already bounds the escalated case at 40 Gpx. That is a
mathematical bound, not a memory-safety one: at ~4 bytes per pixel it
permits ~160 GB of page images, and the concrete case of 200 A4 pages
escalating 3 -> 9 is 9.0 Gpx (~36 GB) -- inside both existing limits and
still enough to OOM an ordinary worker. Dropping the aggregate ceiling
was a regression against the ~4 GB it used to provide.

Restores it at 6 Gpx (~24 GB), summed from the per-page peaks so the
render being replaced is counted alongside its replacement.

The reason the earlier 3 Gpx value looked unusable was a mistake on my
part: it does not reject the reported document. The 31-page A4 PDF peaks
at 1.4 Gpx even at 9x, so it cleared that ceiling all along -- what
rejected it was budgeting every page at the worst-case scale, which is
what this PR removed. At 6 Gpx it now passes at every zoomin from 1 to
6, a 100-page A4 document still parses at the default zoom, and the
36 GB case is rejected.

Tunable via XINFERENCE_MAX_PDF_PARSE_RETRY_TOTAL_PIXELS.
@OliverBryant

Copy link
Copy Markdown
Collaborator Author

End-to-end re-verified on GPU with the retry ceiling in place

Re-ran on the RTX 3090 Ti against real DeepDoc inference, with all four commits applied (e45894915 + this branch). Same 31-page A4 document as before, plus 100- and 200-page A4 documents to exercise the new ceiling.

The reported document — 31 pages

zoomin before the fix now
1 400 parsed, 1054 elements, 41.8s
2 400 parsed, 1054 elements, 24.4s
3 (default) 400 parsed, 1054 elements, 31.5s
4 400 parsed, 1054 elements, 42.1s
5 400 parsed
6 400 parsed, 1054 elements

Every zoomin now works, where the previous revision of this PR only admitted 1, 3 and 4. Element counts are identical across all of them.

The ceiling does what it is there for

document zoomin result
100-page A4 3 parsed, 3400 elements (100 title + 3300 text), pages 1–100
100-page A4 4 400 → retry with `zoomin` 3
200-page A4 3 400 → peak above 6043013276 ... exceeding the retry limit of 6000000000; split the document

The 200-page case is the ~36 GB scenario raised in review, now rejected. The 100-page case confirms the ceiling is not so tight that it costs ordinary long documents the default zoom.

Followed the advice mechanically once more: 100 pages at zoomin=4 is rejected, the message names zoomin 3, and that parses — 3400 elements with page numbers covering 1 through 100 and no gaps.

One thing worth recording, unrelated to this PR

Running five or six large parses back-to-back against a single model instance eventually fails with ONNXRuntimeError ... BFCArena ... Available memory of 0. It is GPU memory fragmentation in the model process, not a validation problem: zoomin=6 on the 31-page document failed that way at the end of a long run, then parsed cleanly (1054 elements) on a freshly launched instance, as did the 100-page document. It reproduces regardless of these changes and is a separate issue from the size budgets.

Cleanup

The test instance, repo copy, test home and PDFs were removed; GPU memory and the four pre-existing services are back to their prior state.

@OliverBryant
OliverBryant requested a review from qinxuye August 12, 2026 06:06

@qinxuye qinxuye left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One non-blocking documentation consistency issue remains after the final retry-ceiling change.

Comment thread xinference/api/pdf_ocr.py Outdated
Restoring the aggregate retry ceiling left several comments describing
the state between the two revisions: the constant block claimed there
was no document-wide retry ceiling immediately above the one that
defines it, and both `_parse_budget_error` and `validate_pdf_for_parse`
documented two budgets where there are now three. Two test comments also
still described 200-page A4 documents as required to be admitted, which
their own assertions had already stopped claiming.

All of them now describe the shipped policy: per-page at the worst-case
scale, whole-document at the requested scale, and a looser
whole-document ceiling on the escalated peak.

No behaviour change.

@qinxuye qinxuye left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@OliverBryant
OliverBryant merged commit 5ee3bbe into xorbitsai:main Aug 12, 2026
13 of 14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants