Skip to content

Batch OCR: read the pages on several threads - #1102

Merged
trypsynth merged 6 commits into
trypsynth:masterfrom
dan1982code:ocr-multithread
Oct 13, 2026
Merged

trypsynth merged 6 commits into
trypsynth:masterfrom
dan1982code:ocr-multithread

Conversation

@dan1982code

@dan1982code dan1982code commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Rebased onto master now that #1101 — the --pages fix this was stacked on — has merged, so the
diff below is the threading only.

Batch OCR read a page and moved to the next one, on one thread. A scan of a few hundred pages is
therefore a few hundred page-times long, and a page-time is mostly waiting. This reads pages on a
pool of threads instead, and the pool lives in paperback-core so pb --ocr-image-pages and
pb --ocr-text-pages use it as well.

The user-facing behaviour is unchanged, apart from how often the reader is interrupted — see §6.
That is the thing most of this PR is about.

1. Where the time actually goes, before designing anything

I measured the two halves on a scanned 20-page PDF rendered at 300 DPI, release build, 16 cores:

  • about 55 ms to rasterize a page, and
  • about 135 ms to read the text off it, at 18.6 MB of RGBA per page.

Recognizing scales nearly linearly — 3.7x on four threads, 6.4x on eight. Rasterizing barely
scales at all: 1.26x on two threads, 1.45x on eight.
That is pdfium-render's thread_safe
feature, which serializes every binding call through one process-wide mutex;
FPDF_RenderPageBitmap is a single call, so the whole rasterization is inside it. The small
residual gain is bgra_to_rgba, which is pure Rust and runs after the lock is dropped.

So the shape is not "as many threads as the machine has". A pipeline cannot beat its slowest stage,
and past about four workers pages are claimed faster than pdfium can rasterize them. The pool
therefore has each worker render and recognize, each with a renderer of its own, and caps itself
at four: the serialized half is worth having several threads in only because the other half is
running alongside it.

2. The pool, and why it is in core

crates/paperback-core/src/ocr/pool.rs. recognize_pages takes the page numbers, a cancel flag and
a recognize_page callable, and hands each page's result to an on_page closure. The OCR engine is
platform-specific and lives in the front end, so it arrives as a call rather than as a dependency —
core gains no engine, and the pool is testable in core against a stand-in.

Both front ends now use it: the app's run_ocr and pb's pages. pb is arguably where this
matters most, since scripting a folder of scans is the case the CLI exists for.

Two costs worth stating:

  • Each worker opens the document itself. A renderer belongs to the thread that made it, and the
    pages have to be rendered on the thread that will read them, so a four-worker job parses the file
    four times. Once per job, overlapped, and small next to the reading — but it is a real cost and
    not a free lunch.
  • Four pages are in flight at once, roughly 19 MB each, plus the engine's own copy of the one it
    is reading. That is a few hundred MB on a large scan rather than the one page it used to be.

3. Order is kept, and that is not incidental

Pages finish in whatever order the workers happen to take, and the results are put back into page
order before the caller sees them. Without that, three things that are load-bearing today break:

  • refresh_after_ocr reloads the text window whenever a flush holds a page at or before the
    reader's window. Today pages land in ascending order, so once the reader is past a page it never
    reloads again. A straggling page 10 arriving while the reader is on page 300 would yank the window
    and re-set the insertion point under them.
  • The progress announcement counts pages done, and a count that goes backwards reads as a bug.
  • A flush is applied as one replace_ranges batch, and the pages after a re-OCR'd page move.
    Page numbers are re-resolved per flush, so out-of-order application is correct — it is the
    reader-visible churn that is not.

Out-of-order results are held until the pages before them arrive. What is held is a page of text,
not the bitmap it came from, so a whole document waiting costs kilobytes.

4. Stopping

cancel is checked at each worker's next page boundary, so a stop is still about one page away.
Pages already read are still handed over, which is what the closing announcement counts. The pool
returns whether it stopped short, rather than the app reading the cancel flag afterwards and having
to guess whether the job finished first.

A stop leaves a gap: the pages after it are unread and the ones already read may sit behind the gap.
Those are handed over at the end anyway — the text was read, and dropping it would make "N pages
recognized" describe text nobody got.

5. Two lines that are not the threading

PageRenderer's doc comment said the type is not Send. It is — the pool's tests compile
against it as Send — and PdfDocument is explicitly Send a few hundred lines away for exactly
this reason. The one-thread-per-renderer rule is real and the pool keeps it, but nothing enforces
it, so the comment now says that and gives the actual reason for the rule: pdfium's global lock
serializes rasterizing, so sharing one renderer between workers would queue them behind each other
rather than let them overlap. Two lines of comment, no behaviour.

if batch && done % BATCH_OCR_PROGRESS_EVERY == 0 became done.is_multiple_of(...). The
expression is otherwise untouched, and it is not flagged on master.

I could not explain it, so I reproduced it in a scratch crate rather than leave a guess in the
history. The lint's behaviour turns on the code around the expression, not on the expression: the
same if batch && done % N == 0 && done != total is flagged in one enclosing shape and not in
another, with no difference in the modulo itself. It looks like a clippy bug, but I could not reduce
it to a rule, so I am describing it rather than diagnosing it.

I took the suggestion because clippy::all is denied through [workspace.lints] and the two are
equivalent for usize. An #[allow] on the one line would do just as well if anyone would rather
keep the original spelling; is_multiple_of has the advantage that it will not be flagged if the
lint's behaviour shifts again.

6. The one thing the reader will notice: progress is announced less often

The pool made a page about four times quicker, which made the progress announcement arrive about
four times as often without anything about the reader having changed. At 20 pages that was roughly
one a second — near enough to hear the next before the last had finished. It is now 50, which at
about 30 ms a page is an announcement every second and a half, close to the cadence a reader had
before the pool and loud enough to be worth hearing.

The flush interval went to 50 with it. That is the one place this change is not purely internal:
text now lands in ~1.5-second groups rather than ~0.6. In exchange each apply rebuilds the buffer's
per-char index tables, which is linear in the whole document, so a 500-page job now rebuilds them
ten times instead of twenty-five — a few percent of the run and less UI-thread work per job, though
nothing like the pool's own 3.7x. Keeping the two equal also keeps the announcement honest: the
flush is queued to the UI thread immediately before it, so a reader who goes to page 50 on hearing
"OCR 50 of 300" finds the words there rather than a placeholder.

7. What this does not do

  • It does not make a scan dramatically faster than about 3.7x, and the ceiling is rasterizing,
    not reading. Getting past it means either rendering at a lower DPI — my measurement says a ~200
    DPI render takes the per-page cost from 185 ms to 117 ms, at the cost of accuracy, which is a
    product decision rather than a performance one — or running several pdfium processes, since the
    lock is process-wide.
  • TARGET_DPI is untouched. Still 300.
  • No worker-count setting. default_workers() leaves a core for the thread driving the run and
    clamps to four. If a reader wants to run the machine flat out while doing something else, there is
    no dial for it yet.
  • The app's speech is the same apart from the cadence. Same wording, same counts; progress is
    announced every 50 pages rather than every 20, as §6 explains.

8. Testing

Automated: 12 new cases in ocr/pool.rs over a generated comic archive and a stand-in engine —
every page read exactly once, results handed over in page order when recognition finishes in
reverse, that pages really are read concurrently (a run that quietly went sequential would pass
every other case), a page that will not read not stopping the rest, a page the document does not
have being reported rather than skipped, a stop reading no more and still handing over what it read,
a stop before the first page, an empty list, a document that will not open, and more workers than
pages.

End-to-end, pb over 299 pages of an 800-page PDF: 38.0 s before, 10.2 s after, 3.72x, with the
output byte-identical to the unthreaded run. The comparison is against a binary rebuilt from
master's OCR code rather than the one already sitting in target/, which turned out to be stale
and would have made the number meaningless.

Full suite on this branch, rebased onto master: 1469 paperback-core lib, 169 pb, 182 app
passing; clippy clean on CI's command; formatted with the pinned nightly.

The app's own Batch OCR has been run by hand on Windows — a real scan, through the dialog, with
NVDA — and worked. What I would still watch on a longer job: whether four workers contending for
pdfium's lock makes the UI thread sluggish mid-batch, since it needs that same lock, and that
cancelling and F5 during a batch still land cleanly.

paperback-core: src/ocr.rs (the module, and PageRenderer's doc comment),
src/ocr/pool.rs (new).
pb: src/ocr.rs (the pool, and the results keyed back onto the edits).
paperback: src/ui/document_manager/ocr.rs (run_ocr drives the pool).

🤖 Generated with Claude Code

dan1982code and others added 3 commits October 12, 2026 14:08
A scan of a few hundred pages is a few hundred page-times long, and a page
is mostly spent waiting. Batch OCR read one page at a time; it now reads
them on a pool of threads, which lives in `paperback-core` so `pb` uses it
too.

Measured on a scanned 20-page PDF at 300 DPI: about 55 ms to rasterize a
page and 135 ms to read it. Recognizing scales nearly linearly across
threads -- 3.7x on four, 6.4x on eight -- but rasterizing barely scales at
all, 1.45x on eight, because `pdfium-render`'s `thread_safe` feature
serializes every call through one process-wide mutex and
`FPDF_RenderPageBitmap` is a single call. So a page is claimed, rasterized
and read by the same worker, each opening a renderer of its own, and the
pool caps itself at four: past that, pages are claimed faster than pdfium's
one-at-a-time rasterizer can serve them.

Order is kept on the way out. Pages finish in whatever order the workers
take, and the results go back into page order before the caller sees them.
That is not tidiness: an early page arriving late would reload the reader's
text window and re-set the insertion point under them, and the progress
count would appear to go backwards.

`pb` over 299 pages of an 800-page PDF: 38.0 s to 10.2 s, with the output
byte-identical to the unthreaded run.

`PageRenderer`'s doc comment said the type was not `Send`. It is, so the
comment now says the one-renderer-per-thread rule is a rule rather than a
property, and gives the reason for it.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The pool made a page about four times quicker, which made an announcement
arrive about four times as often without anything about the reader changing.
At 20 pages that was roughly one a second, near enough to hear the next
before the last one had finished.

The two intervals were the same constant, so this splits them. The flush
interval is how long the reader waits to be able to read the text, and that
has not changed; the announcement is how often they are interrupted, and
that had to. The cost of the split is that the announcement no longer lands
on a flush, so the count can run a few pages ahead of what is on screen --
which the count never described anyway, since it counts pages read rather
than pages applied.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The flush interval was split from the progress interval in the previous
commit, because the announcement had to get less frequent while the reader's
wait for the text did not. Fifty turns out to be the right number for both,
so they are one constant again -- which also restores the pairing the two
had before the pool: the apply is queued to the UI thread immediately before
the announcement at the same page, so "OCR 50 of 300" is true when it is
heard rather than a few pages ahead of the text.

It is also the fewest applies. Each one rebuilds the buffer's per-char index
tables, linear in the whole document, so a 500-page job rebuilds them ten
times instead of twenty-five. Worth a few percent of the job rather than
anything like the pool's 3.7x, and free.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Benchmark figures and narration move out of the comments, the test docs that restated their names go, and the blank lines inside recognize_pages are removed.
A fixed page count tuned for a many-core machine leaves a low-end one silent: with one worker, 50 pages is close to ten seconds between announcements and before the first text lands. Folding pages in and announcing progress every four seconds keeps the same cadence on any machine, and still bounds how often the buffer's index tables are rebuilt.
@trypsynth
trypsynth merged commit da0e738 into trypsynth:master Oct 13, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants