Repository navigation
Every page decodes all Flate images of a shared /Resources dictionary (~150 MB per page result for LibreOffice PDFs) #424
Description
Summary
When pages share one /Resources dictionary, which is how LibreOffice writes PDFs, each page result holds the decoded pixels of every Flate-compressed image listed in it, whether that page draws the image or not. The memory stays allocated as long as the PageParseResult lives. Text output is correct (bitmap_resources lists only images the page draws); only memory is affected.
Reproduce (docling-parse 7.23.0, Linux x86_64)
repro_shared_resources.py (below) writes two 100-page PDFs with identical content: text on every page, and 8 Flate-compressed 1600×1200 RGB images drawn only on page 1. In shared.pdf every page points at one /Resources listing the 8 images; in unshared.pdf only page 1 lists them. It then parses each file with DoclingThreadedPdfParser and keeps every PageParseResult:
shared.pdf render pages=100 rss before=201 MB, after keeping all results=15076 MB
shared.pdf no-render pages=100 rss before=200 MB, after keeping all results=14885 MB
unshared.pdf render pages=100 rss before=201 MB, after keeping all results=1133 MB
unshared.pdf no-render pages=100 rss before=200 MB, after keeping all results=944 MB
That is ~148 MB per page result in shared.pdf, about the size of the 8 decoded images (8 × 1600 × 1200 × 3 B = 46 MB raw, plus copies). With JPEG (/DCTDecode) images instead of Flate the difference is small (~4 MB per page), so it looks specific to images that are inflated up front.
repro_shared_resources.py
# pip install docling-parse==7.23.0 pillow
# python repro_shared_resources.py
"""Each page holds every Flate image of a shared /Resources dictionary.
Writes two 100-page PDFs with identical content: text on every page and 8
Flate-compressed 1600x1200 RGB images drawn only on page 1. In shared.pdf all
pages point at ONE /Resources dictionary listing the 8 images (as LibreOffice
writes it); in unshared.pdf only page 1 lists them. Then it parses each file
with DoclingThreadedPdfParser, keeps every PageParseResult (as a caller that
needs the pages later does) and reports RSS.
"""
import io
import random
import resource
import zlib
from PIL import Image, ImageFilter
from docling_parse.pdf_parser import DoclingThreadedPdfParser, RenderConfig, ThreadedPdfParserConfig
W, H = 595.28, 841.89
def image_bytes(seed, size=(1600, 1200)):
rnd = random.Random(seed)
small = Image.new("RGB", (40, 30))
small.putdata([tuple(rnd.randrange(256) for _ in range(3)) for _ in range(40 * 30)])
return small.resize(size, Image.BICUBIC).filter(ImageFilter.GaussianBlur(4)).tobytes()
def write_pdf(path, shared, n_pages=100, n_images=8):
objs = []
def add(body):
objs.append(body)
return len(objs)
font = add(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>")
imgs = []
for k in range(n_images):
data = zlib.compress(image_bytes(k))
imgs.append(add(b"<< /Type /XObject /Subtype /Image /Width 1600 /Height 1200 /ColorSpace /DeviceRGB "
b"/BitsPerComponent 8 /Filter /FlateDecode /Length %d >>\nstream\n" % len(data) + data + b"\nendstream"))
xobj = b" ".join(b"/Im%d %d 0 R" % (k, r) for k, r in enumerate(imgs))
with_images = add(b"<< /Font << /F1 %d 0 R >> /XObject << %s >> >>" % (font, xobj))
text_only = with_images if shared else add(b"<< /Font << /F1 %d 0 R >> >>" % font)
draw = b"\n".join(b"q 120 0 0 90 %d %d cm /Im%d Do Q" % (40 + 130 * (k % 4), 600 - 100 * (k // 4), k) for k in range(n_images))
text = b"BT /F1 10 Tf 12 TL 50 800 Td " + b" ".join(b"(Line %d of some body text on this page.) Tj T*" % i for i in range(60)) + b" ET"
pages_id = len(objs) + 1 + 2 * n_pages
kids = []
for i in range(n_pages):
content = zlib.compress(text + (b"\n" + draw if i == 0 else b""))
cid = add(b"<< /Length %d /Filter /FlateDecode >>\nstream\n" % len(content) + content + b"\nendstream")
kids.append(add(b"<< /Type /Page /Parent %d 0 R /MediaBox [0 0 %.2f %.2f] /Resources %d 0 R /Contents %d 0 R >>"
% (pages_id, W, H, with_images if i == 0 else text_only, cid)))
add(b"<< /Type /Pages /Kids [%s] /Count %d >>" % (b" ".join(b"%d 0 R" % k for k in kids), n_pages))
root = add(b"<< /Type /Catalog /Pages %d 0 R >>" % pages_id)
out, offsets = bytearray(b"%PDF-1.7\n"), []
for i, body in enumerate(objs, 1):
offsets.append(len(out))
out += b"%d 0 obj\n" % i + body + b"\nendobj\n"
xref = len(out)
out += b"xref\n0 %d\n0000000000 65535 f \n" % (len(objs) + 1) + b"".join(b"%010d 00000 n \n" % o for o in offsets)
out += b"trailer\n<< /Size %d /Root %d 0 R >>\nstartxref\n%d\n%%%%EOF\n" % (len(objs) + 1, root, xref)
open(path, "wb").write(out)
def rss_mb():
with open("/proc/self/status") as f:
return next(int(l.split()[1]) // 1024 for l in f if l.startswith("VmRSS:"))
if __name__ == "__main__":
import sys
if len(sys.argv) == 1:
import subprocess
write_pdf("shared.pdf", shared=True)
write_pdf("unshared.pdf", shared=False)
for pdf in ("shared.pdf", "unshared.pdf"):
for render in ("render", "no-render"):
subprocess.run([sys.executable, __file__, pdf, render], check=False)
else:
pdf, mode = sys.argv[1], sys.argv[2]
cfg = ThreadedPdfParserConfig(render_config=RenderConfig() if mode == "render" else None)
parser = DoclingThreadedPdfParser(parser_config=cfg)
parser.load(pdf)
before = rss_mb()
kept = [r for r in parser.iterate_results()]
print(f"{pdf:13s} {mode:9s} pages={len(kept)} rss before={before} MB, after keeping all results={rss_mb()} MB, "
f"peak={resource.getrusage(resource.RUSAGE_SELF).ru_maxrss // 1024} MB")Why it matters
Callers rarely hold just one page:
- the threaded parser buffers up to
max_concurrent_results(32) results ahead; onshared.pdfa streaming parse that drops each page after use still peaks at 5.9 GB (2.7 GB withmax_concurrent_results=1); - docling keeps every page backend until the end of the conversion when any enrichment option is on (
keep_backend), so the cost becomes pages × decoded images.
In docling-serve 1.37/1.38 (local engine, standard pipeline, formula/code enrichment and picture classification on) a real 100-page report with 14 images (22 MP total, Flate) and one /Resources shared by all pages peaked at 17.7 GB and was OOM-killed in production at a 24 GiB limit when uploaded twice. shared_flate above, converted with the same settings, peaks at 20.0 GB; the unshared variant at 8.7 GB. With pdf_backend=pypdfium2 the same report peaks at 5.1 GB.
Expected
Decode an image XObject only when the page's content stream draws it (Do), or share decoded resources between pages of a document instead of copying them into every page decoder.
Related: docling-project/docling-serve#366 (memory not released), docling-project/docling-serve#745 (max_concurrent_results in the server), #345 (vector-dense pages).
Activity
wittjeff commentedon Oct 26, 2026
Thanks for the clear repro. I can confirm it on
upstream/main(15c3f6a) and on 7.23.0.Root cause
pdf_decoder<PAGE>::decode_xobjects()callspdf_resource<PAGE_XOBJECTS>::set(), which parses every key of the page's/XObjectdictionary. For each image,pdf_resource<PAGE_XOBJECT_IMAGE>::parse()then callsinit_stream_data()andinit_soft_mask_data(). These calls keep the raw bytes and the defiltered samples (QPDFgetStreamData()) in the resource. The page decoder holds the resource as long as thePageParseResultlives.The
xobject_parse_cache(#285) removes duplicates only inside one page decoder. The threaded parser opens a separateQPDFdocument for each page, so pages do not share decoded resources. Thus, when all pages point at one/Resources, each page holds one decoded copy of every image in it.With
/DCTDecode, QPDF does not inflate the stream, so each page holds only the compressed bytes (twice). That is why JPEG documents show only a small difference.Measurement
I used my own generator with the same structure as yours: 20 pages, 8 RGB 1600×1200 images in one shared
/Resources, drawn only on page 1. Values are RSS growth per keptPageParseResult:With the fix, a shared
/Resourcescosts the same as an unshared one.Proposed fix
Decode the samples on first use, not in
set().parse()still reads the dictionary (size, colour space, filters,/Decode, palette), because that is small. A newload_pixels() construnsinit_stream_data()andinit_soft_mask_data()the first time a pixel getter is called (get_raw_stream_data(),get_decoded_stream_data(),get_soft_mask_data(), and the others). OnlyDo_image(pdf_states/bitmap.h) andsave_to_file()call these getters, so a page decodes only the images it draws. The pixel members aremutable. This is safe because one page decoder runs on one thread, and its QPDF document stays alive untilDoruns.The change is in one header (
src/parse/pdf_resources/page_xobject_image.h). A new unit test builds an 8-page PDF with one shared 34 MB Flate image and limits the RSS growth of the kept results. On 7.23.0 it fails (311 MB). With the fix it passes. All unit tests pass (284 passed, 1 skipped), and the regression suite passes on the pinned dataset revision.This does not change the other two points in your report:
max_concurrent_resultsbuffering in the threaded parser, and docling keeping every page backend when enrichment is on. With the fix, each buffered page costs only what it draws, so both become much smaller.The fix is in #425.
fix: keep only the resources a page uses in its one-page buffer
fix: keep only the resources a page uses in its one-page buffer