• Stefy Lanza (nextime / spora )'s avatar
    ocr: rasterise PDFs one document per process (v0.3.67) · 9c643e75
    Stefy Lanza (nextime / spora ) authored
    0.3.66 serialised every pdfium call behind a lock, because pdfium permits one
    caller at a time and calling it concurrently corrupts its state into "PDFium:
    Data format error" on valid documents. Correct, and it made rasterisation
    single-threaded for the whole install -- a ceiling of about one core.
    
    pdfium IS safe one-document-per-process, so `ocr.raster_workers` (0 = auto at
    half the cores capped at 4, 1 = in-process under the lock) hands documents to
    worker processes. Measured on 12 concurrent 47-page PDFs at 150 DPI: 21.9s ->
    10.3s, 2.13x. Not 4x -- the parent is single-threaded receiving the pixels and
    rebuilding the images, and ~4.5 GB crosses the pipes. The ceiling is the parent.
    
    Three things this had to get right, each of which cost a bug first:
    
    - A worker is borrowed per CHUNK, not per document. The caller OCRs each window
      between pulls, which takes far longer than rasterising it, so holding a worker
      across that would idle it for most of its tenure and cap the install at
      `raster_workers` concurrent documents -- worse than the lock it replaces.
    
    - A pool that cannot start must cost nothing. The first version waited 300s for
      a worker that could never arrive (missing script), stalling every document for
      five minutes: the optimisation becoming an outage, which is the exact failure
      its test was written for. Creation failure is now remembered rather than
      retried per document, and a busy pool falls back in-process after 30s.
    
    - Workers are executed, never imported. `import codai.ocr` costs 377 MB and
      1.5s via codai.models.manager -> litellm; paying that per worker, in the
      subsystem whose other half exists to bound memory, would be self-defeating.
      The worker imports pypdfium2 and PIL only, ~25 MB. This is why it is not a
      ProcessPoolExecutor: spawn pickles a function by qualified name and re-imports
      the package to find it.
    
    Also fixes a cost 0.3.65 introduced and nothing but a stopwatch would surface:
    pricing a document called `pdf[i].get_size()` per page, which LOADS each page
    object -- 593 ms for a 47-page document, paid UNDER THE LOCK, so every document
    in the install queued behind every other being priced.
    `pdf.get_page_size(i)` reads the same numbers from the page tree by index.
    page_estimate: ~600 ms -> 9.6 ms.
    
    Every failure path degrades to a correct in-process render: no pool, a dead
    worker mid-document, a missing script, an unusable module, an abandoned
    document. Correctness never depends on the optimisation working.
    
    87 tests across the budget and pool suites. Full suite at the 25 pre-existing
    failures.
    Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01HXRMe9ZPUQCCCtBai9uqt3
    9c643e75
Name
Last commit
Last update
..
schemas_builtin Loading commit data...
workers Loading commit data...
__init__.py Loading commit data...
base.py Loading commit data...
budget.py Loading commit data...
chandra.py Loading commit data...
detect.py Loading commit data...
doctr.py Loading commit data...
extract.py Loading commit data...
gateway.py Loading commit data...
manager.py Loading commit data...
olmocr.py Loading commit data...
paddle.py Loading commit data...
rasterize.py Loading commit data...
resume.py Loading commit data...
schemas.py Loading commit data...
served_model.py Loading commit data...
subprocess_engine.py Loading commit data...
surya.py Loading commit data...
venv_build.py Loading commit data...