Skip to content

load() spends minutes and GBs on the AcroForm while holding the GIL (signed PDFs) #399

Open

Description

@alexandre-piccin

load() spends minutes and several GB processing the document's AcroForm, all while holding the GIL. On digitally signed PDFs (~80–90 pages, 84–112 form fields incl. /Sig widgets) this freezes every other Python thread for ~2 min, and one of them runs out of memory (> 9 GB) before a single page is decoded.

Removing only the catalog's /AcroForm makes the problem disappear:

91-page PDF Load Conversion Peak memory Longest event loop freeze
as is 111 s 232 s 6.6 GB 117 s
/AcroForm removed 0.0 s 116 s 2.7 GB 0.0 s

The 80-page PDF goes from OOM at 9 GB to 77 s / 2.9 GB. The Markdown output is byte-identical with and without /AcroForm.

Other observations:

  • load(..., page_range=(1, 1)) still takes 111 s / 3.7 GB, so the cost is document-level.
  • Every 10-page slice of the same PDF (pages copied into a new file) loads in 0.0 s.
  • Removing page /Annots changes nothing; parser_threads=1 and release_native_memory_every_n_pages=8 don't help.
  • faulthandler shows the thread stuck on docling_parse/pdf_parser.py:1598 (self._parser.load_document_from_bytesio(...)) for the whole time; file path input behaves the same.

Reproduction

import time
import pikepdf
from docling_parse.pdf_parser import DoclingThreadedPdfParser

def load_time(path: str) -> float:
    start = time.monotonic()
    DoclingThreadedPdfParser().load(path, page_range=(1, 1))
    return time.monotonic() - start

with pikepdf.open("signed.pdf") as pdf:
    del pdf.Root["/AcroForm"]
    pdf.save("signed_no_acroform.pdf")

print(load_time("signed.pdf"), load_time("signed_no_acroform.pdf"))

Suggested fix

  • Extract the AcroForm lazily (only when get_annotations() is called) or behind an option, and avoid whatever makes it scale this badly. My guess: widget /P references back to pages get expanded repeatedly into the raw dict.
  • Release the GIL during the native load (py::gil_scoped_release).

docling 2.132.0 · docling-parse 7.22.1 · Python 3.14 · Linux

Activity

  1. changed the title [-]Document load holds the GIL (~1 s/page), freezing other Python threads[/-] [+]load() spends minutes and GBs on the AcroForm while holding the GIL (signed PDFs)[/+] on Oct 9, 2026
  2. wittjeff commented on Oct 16, 2026

    @wittjeff
    Contributor

    Thanks for the precise report; it made this quick to pin down. I could reproduce it with a synthetic file, and the fix is in #410.

    What happens. load() builds the document annotations eagerly, and that serialises /AcroForm to JSON (to_json() in src/parse/qpdf/to_json.h). Your signature dictionaries carry /Reference[*]/Data, which Acrobat and iText point at the catalog for DocMDP and FieldMDP signatures. From the catalog the serialiser walks the page tree and the structure tree, and its cycle guard only looks at the current path, so every structure element's /Pg expands its page again, fonts included. That is why the cost is document-level, why page_range and /Annots make no difference, and why page slices load instantly: a copied slice has neither the catalog's /AcroForm nor its /StructTreeRoot. The widget /P entries are not the route; those are already skipped.

    Numbers on a 33-page tagged PDF from the regression corpus (15.6k structure elements, 6.4k /Pg references), DoclingThreadedPdfParser().load(path, page_range=(1, 1)), peak RSS:

    before after
    untouched 0.02 s, 125 MB 0.02 s, 85 MB
    + DocMDP signature with /Data → catalog 48.6 s, 2.0 GB 0.06 s, 85 MB
    + same signature, /StructTreeRoot removed 0.12 s –

    The fix (#410): to_json() stops at catalog, page-tree and structure-tree dictionaries and writes a reference instead; the cycle guard keys on object ids instead of unparsed text; and the annotations are built on the first get_annotations() call instead of at load, so a pages-only conversion never runs this at all.

    Releasing the GIL during the native load is not in that PR. After the fix the load is qpdf's parse only, and releasing the GIL there needs a mutex around the per-key decoder maps against concurrent load() calls on one parser. If it still matters for your event loop once the fix is out, it is worth its own issue.

    If you can check that your files take the same route, that would confirm it:

    import pikepdf
    with pikepdf.open("signed.pdf") as pdf:
        root = pdf.Root.objgen
        for field in pdf.Root.AcroForm.Fields:
            if field.get("/FT") == "/Sig" and "/V" in field:
                for ref in field.V.get("/Reference", []):
                    data = ref.get("/Data")
                    print(field.get("/T"), ref.get("/TransformMethod"), data is not None and data.objgen == root)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions