@@ -4,6 +4,31 @@ Post-migration review of the PDF processing path: where the time actually goes,
44what was measured, which optimizations are validated, and a ranked backlog of
55further ideas that do ** not** trade away output quality.
66
7+ ## Results at a glance
8+
9+ Everything below was landed across two optimization rounds (PR #26 , #27 ),
10+ each change gated on corpus conformance — groundtruth distance unchanged or
11+ better, byte-identical where the change is structural:
12+
13+ | Optimization | Measured effect |
14+ | ---| ---|
15+ | INT8 layout model (Conv-only static QDQ, calibrated; ** default** ) | layout inference ** 2.4×** faster; ** 1.83× end-to-end** on a 1913-page PDF (0.74 → 0.40 s/page) |
16+ | INT8 TableFormer decoder (dynamic, ** default** ) | ~ 10% faster table decode, byte-identical |
17+ | SIMD page downscale (` fast_image_resize ` , same kernel; ** default** ) | ` image.resize ` stage ** 17×** faster (2607 → 152 ms / 16 pages) |
18+ | TableFormer KV cache fed back as ` ort ` values (no per-step copy) | ~ 9% faster table-structure decode, byte-identical |
19+ | One shared lazy TableFormer across the worker pool | peak RSS ** 3.8 → 1.9 GB** (4 workers); table-free docs 682 → 331 MB |
20+ | Single shared line/word contraction pass | ` --no-ocr ` conversion ~ 1.25× faster, identical output |
21+ | Per-document font + form caches in the text parser | 3–10% off ` textparse ` here; far more on CJK/form-heavy PDFs |
22+ | True-KV-cache decoder export (` decoder_kv.onnx ` , optional) | parity at corpus table sizes; O(past)/step for very large tables |
23+
24+ Cumulative head-to-head vs Python docling (measured on an 8-thread desktop,
25+ ` scripts/performance.sh ` ): ** 4.3× faster warm conversion, 4.7× end-to-end,
26+ 2.3–2.6× less peak memory** on the PDF ML pipeline — up from ~ 1.2× warm
27+ before this work. Model sizes: layout 172 → 68 MB, TF decoder 78 → 50 MB.
28+ Also fixed along the way: the ` " ` show-text operator dropped its word/char
29+ spacing operands (real spec violation), and OCR/TableFormer sub-stages are
30+ now visible in ` FLEISCHWOLF_TIMING ` profiles.
31+
732Measured on a 4-core AVX-512(+VNNI/AMX) Xeon, release build (` lto = "thin" ` ),
833models from ` scripts/download_dependencies.sh ` , ` FLEISCHWOLF_TIMING=1 ` .
934
@@ -122,32 +147,43 @@ Ordered by expected impact ÷ risk. Items 1–3 attack the 85–95%.
122147 the cache and the encoder's cross-K/V + ` enc_out ` stay owned ` ort ` values
123148 fed straight back into the next run (~ 9% faster structure decode,
124149 byte-identical output).
125- - The exported graph still re-embeds the ** full tag sequence** every step
126- (` tags ` grows each iteration) even though attention is KV-cached. Re-export
127- the decoder to take only the last tag (the cache carries the history) —
128- this is the remaining per-step cost; see ` scripts/export_tableformer.py ` .
150+ - ~~ The exported graph still re-embeds the ** full tag sequence** every
151+ step.~~ ** Built and measured:** ` scripts/export_tableformer.py ` now also
152+ exports ` decoder_kv.onnx ` , a true-KV-cache step (one tag in, projected
153+ K/V cached per layer), verified argmax-identical over a 64-step rollout
154+ and byte-identical on corpus output. Measured result: ** parity** with
155+ the legacy graph on corpus-sized tables (~ 100–300 tokens) — ONNX Runtime
156+ executes the legacy graph's full-prefix re-projection as one efficient
157+ batched GEMM, so the O(n²) FLOPs don't become O(n²) wall time until
158+ tables get much larger. The Rust loop auto-detects either graph (input
159+ names) and prefers the smaller legacy file by default; point
160+ ` DOCLING_TABLEFORMER_DECODER ` at ` decoder_kv(_int8).onnx ` for
161+ very-large-table workloads.
1291623 . ** Layout batching for the parallel path** : the pool currently runs batch-1
130163 inference per page. RT-DETR's 640×640 input is fixed-shape, so pages can be
131164 batched (e.g. batch-4) per worker with one session — better core utilization
132165 and less framework overhead on wide machines. Output is per-image, so
133166 quality is unaffected. (Needs a re-export with a dynamic batch dim.)
134- 4 . ** Render → resize pipeline copies** (` pdfium_backend.rs:264-272 ` , ~ 15% on
135- text-heavy docs): pdfium's BGRA bitmap goes through ` as_image() ` (copy +
136- swizzle) then ` .into_rgb8() ` (second copy) before the 3×→2× CatmullRom
137- downsample (third buffer). A single BGRA→RGB pass into the resize source
138- removes one full-page copy + traversal per page. Keep the 1.5× supersample +
139- CatmullRom itself — it is deliberate PIL-BICUBIC parity for model input.
140- Also: when ` layout.predict ` is the only image consumer at 640×640, the
141- 2×-page intermediate is only needed for TableFormer crops and OCR — a page
142- with no table regions and a text layer never needs it; rendering could be
143- deferred/skipped per page in a ` no_ocr ` -like fast path decided * after*
144- layout runs on a cheaper raster.
167+ 4 . ** The 3×→2× page downscale** (~ 15% of a text-heavy conversion, ~ 25% after
168+ INT8): ~~ replace the scalar ` image ` -crate CatmullRom with a SIMD
169+ convolution.~~ ** Done on this branch:** ` fast_image_resize ` with the same
170+ a=-0.5 Catmull-Rom kernel — ` image.resize ` drops ** 2607 → 152 ms (17×)**
171+ on the 16-page doc. The SIMD fixed-point path differs from the scalar one
172+ by ±1/255 on some pixels, which can flip borderline table cells, so it was
173+ gated like INT8: groundtruth distance over the corpus is ** 817 (SIMD) vs
174+ 818 (scalar)** — conformance-neutral. ` FLEISCHWOLF_SLOW_RESIZE=1 ` restores
175+ the scalar path, and ` pdf_conformance.sh ` /` pdf_groundtruth.sh ` pin it so
176+ the committed snapshot baselines stay valid. (The render-side ` as_image() `
177+ copy turned out to be a non-issue: pdfium already renders with reversed
178+ byte order, so it is one memcpy + one 4→3-channel pass, ~ 1% of total.)
1451795 . ** textparse font caching** (marginal for PDFs — textparse is ≤1% — but
146180 real for ` no_ocr ` mode where it becomes the bottleneck):
147- - fonts are fully re-parsed (ToUnicode CMap decompression + tokenization,
148- Type1 program scan, width maps) for ** every page** and every Form-XObject
149- invocation (` textparse.rs:794 ` ); cache parsed ` Font ` s per document keyed
150- by the font dict's ` ObjectId ` , and cache decoded Form XObject content.
181+ - ~~ fonts are fully re-parsed for ** every page** and every Form-XObject
182+ invocation; decoded form content re-inflated per ` Do ` .~~ ** Done on this
183+ branch:** per-document caches keyed by object id (fonts also by resource
184+ name, which feeds the docling-parse font hash). Identical output across
185+ the corpus; 3–10% off the ` textparse ` stage on the test fixtures (their
186+ ToUnicode CMaps are small — CJK/form-heavy documents benefit far more).
151187 - ~~ ` line_cells ` + ` word_cells ` run the identical build+contract twice per
152188 page; one pass can emit both.~~ ** Done on this branch**
153189 (` dp_lines::line_and_word_cells ` ): ~ 1.25× faster ` --no-ocr ` conversion,
@@ -168,6 +204,37 @@ Ordered by expected impact ÷ risk. Items 1–3 attack the 85–95%.
168204 still shave per-worker model-load latency; only worth it if pool spin-up
169205 shows up in a real deployment.
170206
207+ ## Memory
208+
209+ Each pool worker used to own a full model set, so peak RSS scaled with the
210+ pool: on a 4-worker machine ~ 0.4 GB of TableFormer weights+arenas were
211+ duplicated four times even though tables appear on a minority of pages. The
212+ pool now shares ** one lazily-loaded TableFormer** behind a mutex (loaded with
213+ the full intra-op budget, since tables serialise on it anyway; prediction is
214+ independent of which worker runs it). Measured on the 16-page table-heavy
215+ paper, INT8 stack:
216+
217+ | pool | per-worker TF (before) | shared TF (after) |
218+ | ---| ---:| ---:|
219+ | 4 workers | 3816 MB | ** 1880 MB** |
220+ | 2 workers | 2183 MB | ** 1517 MB** |
221+ | 4 workers, table-free doc | 682 MB | ** 331 MB** (TableFormer never loads) |
222+
223+ ` FLEISCHWOLF_PDF_WORKERS ` remains the coarse memory knob on top.
224+
225+ ## Determinism note (pre-existing, worth knowing)
226+
227+ Multi-threaded ONNX Runtime float reductions are ** not deterministic
228+ run-to-run** : on ` 2203.01017v2.pdf ` two identical invocations of the same
229+ binary can differ in a handful of borderline table cells (measured 0–20
230+ Markdown diff-lines between repeat runs, before any of this branch's
231+ changes). ` ocr.rs ` already pins its session to one thread for exactly this
232+ reason. Regression checks for structural changes should therefore compare
233+ outputs under ` FLEISCHWOLF_PDF_THREADS=1 ` (single-thread inference is
234+ deterministic and byte-stable); multi-threaded corpus diffs of a few lines on
235+ table-dense fixtures are thread-scheduling jitter, not necessarily a real
236+ change.
237+
171238## Correctness notes found during review (quality, not speed)
172239
173240- ` textparse.rs ` ` " ` operator: the ` aw ac string " ` form must set word/char
0 commit comments