Threaded PDF Backend (ThreadedDoclingParseDocumentBackend)#
Overview#
ThreadedDoclingParseDocumentBackend is an alternative PDF backend that drives the DoclingThreadedPdfParser from the C++ docling-parse library. It was introduced in PR #3377 as an opt-in backend (pdf_backend = "threaded_docling_parse") designed to pipeline page-parsing work across threads and support periodic C++ memory release — the primary motivation being relief from std::bad_alloc crashes that affect the default serial DoclingParseDocumentBackend on large PDFs.
Key architectural differences from the serial backend:
| Property | DoclingParseDocumentBackend | ThreadedDoclingParseDocumentBackend |
|---|---|---|
| Parser API | DoclingPdfParser (serial) | DoclingThreadedPdfParser (parallel) |
| Page access | load_page(n) — random access | iter_pages() — streaming only; load_page() raises NotImplementedError |
| pypdfium2 handle | Held on document object | No persistent handle; used only for page-range resolution and outline reading |
| Memory release | None (accumulates until close) | release_native_memory_every_n_pages (default: 128) |
The class is defined in docling/backend/docling_parse_backend.py.
Key Implementation Details#
No Random Page Access#
ThreadedDoclingParseDocumentBackend.supports_random_page_access = False and load_page() explicitly raises NotImplementedError . This means the backend is incompatible with VlmPipeline, which requires ordered/random page access — using it with that pipeline is explicitly unsupported.
Pages are consumed via iter_pages(), which wraps parser.iterate_results() and yields ThreadedDoclingParsePageBackend objects (one per PageParseResult). Results arrive in completion order, not document order, which the StandardPdfPipeline producer thread accounts for.
Page-Range Resolution via pypdfium2#
When a non-default page range is requested, _resolve_threaded_page_numbers() opens a temporary pdfium.PdfDocument under pypdfium2_lock purely to obtain the total page count — this is the only pypdfium2 dependency for a normal run. The function clips the end page against the actual count and returns a list[int] of page numbers passed to parser.load().
Outline Extraction and Stream Seek#
get_document_outline() spins up a separate, lazy DoclingPdfParser for structure-only reading because DoclingThreadedPdfParser exposes no table-of-contents accessor. For BytesIO inputs, the stream is explicitly .seek(0)'d before loading to avoid stale stream position from the earlier parser.load() call.
Memory Release#
ThreadedDoclingParseBackendOptions exposes:
release_native_memory_every_n_pages(default:128, set0to disable) — passed toDecodeConfigand controls how often the C++ allocator flushes native memory. Configured via_make_docling_parse_decode_config().parser_threads(default:None, falls back toAcceleratorOptions.num_threads) — controls internal C++ thread count.
CLI caveat:
--release-native-memory-every-n-pagesis silently ignored when any backend other thanthreaded_docling_parseis selected .
Known Bugs and Reliability Issues#
1. Table Detection Correctness Bug (Fixed in PR #3754)#
The most significant correctness issue: the threaded backend was silently dropping tables detected by the serial backend . Root cause traced in PR #3754: the two backends used different rasterizers for page images — serial used pypdfium2, threaded used docling-parse's internal rasterizer — producing slightly different pixel values (~3% of pixels differed). The layout vision model is sensitive to these differences, causing spurious overlapping clusters and the LayoutPostprocessor to evict table clusters, demoting their cells to loose text. The fix routes the threaded backend's get_page_image() through the same pypdfium2 rendering path used by the serial backend .
The bug reproduced even at parser_threads=1, confirming it was not a concurrency race condition .
2. Silent Failure on Large PDFs#
When the threaded backend exhausts C++ native memory on large/image-heavy PDFs, it silently exits with zero output rather than raising an exception — no error is logged, no file is written, and the conversion appears to succeed . This is distinct from the serial backend, which raises a visible std::bad_alloc. The release_native_memory_every_n_pages option exists to mitigate this, but the threaded backend can still run out of memory between flush intervals on very large scanned documents.
3. pypdfium2 Page Count Fallback (Serial Backend)#
The serial DoclingParseDocumentBackend.page_count() cross-checks the count from docling-parse against pypdfium2. When docling-parse's C++ parser fails to parse a PDF's page tree, it can return an invalid negative page count (-1). PR #3040 adds a guard that detects negative values and falls back to the pypdfium2 count, preventing valid documents from being incorrectly rejected . The threaded backend delegates page_count() entirely to parser.page_count(doc_key) , so it does not share this fallback logic.
Configuration Reference#
from docling.datamodel.backend_options import ThreadedDoclingParseBackendOptions
from docling.document_converter import DocumentConverter, PdfFormatOption
converter = DocumentConverter(
format_options={
"pdf": PdfFormatOption(
backend="threaded_docling_parse",
backend_options=ThreadedDoclingParseBackendOptions(
parser_threads=2,
release_native_memory_every_n_pages=32,
),
)
}
)
CLI equivalent (only effective with threaded_docling_parse):
docling --pdf-backend threaded_docling_parse \
--release-native-memory-every-n-pages 32 \
large_doc.pdf
Key Sources#
| Resource | Purpose |
|---|---|
docling/backend/docling_parse_backend.py | Full backend implementation |
docling/datamodel/backend_options.py | ThreadedDoclingParseBackendOptions |
| PR #3377 | Original threaded backend introduction |
| PR #3754 | Table-detection fix (rasterizer unification) |
| PR #3040 | pypdfium2 page-count fallback |
| Issue #3512 | Table-drop bug report |
| Issue #3671 | Large-PDF silent failure report |