Skip to content

Commit b743781

Browse files
authored
Merge pull request #27 from artiz/claude/docling-rust-review-profile-d26fdq
PDF pipeline round 2: pool memory halved, KV-cache TableFormer export, 17× SIMD page downscale
2 parents cc55fa8 + c4ae69c commit b743781

16 files changed

Lines changed: 800 additions & 135 deletions

File tree

.github/workflows/publish-models.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,8 @@ jobs:
123123
stage models/tableformer/encoder.onnx encoder.onnx
124124
stage models/tableformer/decoder.onnx decoder.onnx
125125
stage models/tableformer/decoder_int8.onnx decoder_int8.onnx
126+
stage models/tableformer/decoder_kv.onnx decoder_kv.onnx
127+
stage models/tableformer/decoder_kv_int8.onnx decoder_kv_int8.onnx
126128
stage models/tableformer/bbox.onnx bbox.onnx
127129
echo "staged:"
128130
ls -la release-assets/

MIGRATION.md

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -244,9 +244,19 @@ deliberate scope boundary or a cosmetic, single-fixture polish gap.
244244

245245
## 6. Extensions
246246

247+
Shipped:
248+
249+
- **`fleischwolf-rag`** — documents → chunking → embeddings → vector search,
250+
with swappable embedders (Ollama/Gemini/local ONNX), stores
251+
(SQLite+sqlite-vec / PostgreSQL+pgvector), LLM, sources and queues, plus an
252+
eval harness and a REST API. See the crate README.
253+
- **`fleischwolf-node`** — Node.js/Bun N-API bindings (npm package).
254+
- **MHTML backend** — no docling analogue.
255+
256+
Planned:
257+
247258
- **PyO3 bindings** (`fleischwolf-py`) for a strangler-fig drop-in.
248-
- **C++** bindings
249-
- `fleischwolf-rag` - basic documents processing/chunking/vectorization/semantic-search system with pluggable DB support, PostgreSQL/SQLite, embedding with small ONNX local model (test options, dimensions >= 1024).
259+
- **C++** bindings.
250260

251261
## 7. Testing
252262

@@ -262,6 +272,9 @@ deliberate scope boundary or a cosmetic, single-fixture polish gap.
262272
latest published docling (installed from PyPI; see
263273
[`COMPARING.md`](./COMPARING.md)).
264274
- **Differential / perf**`scripts/compare.sh`, `scripts/performance.sh`.
275+
The PDF pipeline's profiling data, the INT8/SIMD optimization results
276+
(4.3× warm vs Python docling on the ML pipeline), and the remaining
277+
performance backlog live in [`PDF_PERFORMANCE.md`](./PDF_PERFORMANCE.md).
265278

266279
CI (`.github/workflows/ci.yml`) gates every pull request and master push on
267280
`cargo fmt --check`, `cargo clippy --workspace --all-targets -- -D warnings` and

PDF_PERFORMANCE.md

Lines changed: 86 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,31 @@ Post-migration review of the PDF processing path: where the time actually goes,
44
what was measured, which optimizations are validated, and a ranked backlog of
55
further 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+
732
Measured on a 4-core AVX-512(+VNNI/AMX) Xeon, release build (`lto = "thin"`),
833
models 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.
129162
3. **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.)
145179
5. **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

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,33 @@ The comparison scripts install the latest published Python `docling` from PyPI
451451
into `.venv-compare` automatically on first run. See
452452
[`COMPARING.md`](./COMPARING.md).
453453

454+
## Install locally / in CI (one-liner)
455+
456+
`scripts/install.sh` builds the CLI from source and installs a self-contained
457+
tree — for a dev box or a pipeline step:
458+
459+
```bash
460+
curl -fsSL https://raw.githubusercontent.com/artiz/fleischwolf/master/scripts/install.sh | bash
461+
fleischwolf your.pdf > out.md
462+
```
463+
464+
It checks for a Rust toolchain (installs one via rustup if `cargo` is
465+
missing), runs `cargo build --release -p fleischwolf-cli`, installs the
466+
binary + all models + pdfium under `/usr/local/fleischwolf`, symlinks
467+
`/usr/local/bin/fleischwolf`, and writes `/etc/profile.d/fleischwolf.sh` with
468+
the `DOCLING_*`/`PDFIUM_*` exports. The env file is a convenience for other
469+
consumers of the model tree — the CLI itself resolves `models/` and
470+
`.pdfium/` **relative to its own (symlink-resolved) location**, so the
471+
command works from any directory with no environment at all. ONNX Runtime is
472+
statically linked; nothing else lands outside the prefix.
473+
474+
Knobs (env vars before the call): `FLEISCHWOLF_PREFIX` (default
475+
`/usr/local/fleischwolf`), `FLEISCHWOLF_BIN_DIR`, `FLEISCHWOLF_REF` (git ref
476+
to build), `FLEISCHWOLF_NO_ASR=1` (skip the ~150 MB Whisper models),
477+
`FLEISCHWOLF_SUDO=0` (never escalate). Re-running is idempotent — it only
478+
fetches missing model files. Uninstall:
479+
`rm -rf /usr/local/fleischwolf /usr/local/bin/fleischwolf /etc/profile.d/fleischwolf.sh`.
480+
454481
## Deploy in a container
455482

456483
For a real-world service, bake the binary, native libs, and models into one image
@@ -515,8 +542,10 @@ on a 1913-page document — see [`PDF_PERFORMANCE.md`](./PDF_PERFORMANCE.md)).
515542
| `fleischwolf-core` | `DoclingDocument` model + serializers | `docling-core` |
516543
| `fleischwolf` | `DocumentConverter`, source loading, backends | `docling` |
517544
| `fleischwolf-pdf` | PDF/image ML pipeline (pdfium + ONNX layout/table/OCR) | `docling` PDF pipeline |
545+
| `fleischwolf-asr` | audio/ASR pipeline (symphonia + ONNX Whisper) | `docling` ASR pipeline |
518546
| `fleischwolf-cli` | command-line interface | `docling.cli` |
519547
| `fleischwolf-node` | Node.js / Bun N-API bindings (npm package) ||
548+
| `fleischwolf-rag` | RAG layer: chunking, embeddings, vector search, REST API ||
520549

521550
## License
522551

crates/fleischwolf-asr/src/whisper.rs

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,27 @@ pub struct Transcriber {
5050
}
5151

5252
fn model_path(var: &str, default: &str) -> std::path::PathBuf {
53-
std::env::var(var)
54-
.unwrap_or_else(|_| default.to_string())
55-
.into()
53+
if let Ok(p) = std::env::var(var) {
54+
return p.into();
55+
}
56+
// Default is CWD-relative; fall back to the executable's directory and one
57+
// level above it (the `scripts/install.sh` layout, reached through the
58+
// /usr/local/bin symlink), mirroring fleischwolf-pdf's asset resolution.
59+
if !std::path::Path::new(default).exists() {
60+
if let Some(dir) = std::env::current_exe()
61+
.ok()
62+
.and_then(|p| p.canonicalize().ok())
63+
.and_then(|p| p.parent().map(std::path::Path::to_path_buf))
64+
{
65+
for base in [Some(dir.as_path()), dir.parent()].into_iter().flatten() {
66+
let p = base.join(default);
67+
if p.exists() {
68+
return p;
69+
}
70+
}
71+
}
72+
}
73+
default.to_string().into()
5674
}
5775

5876
/// Whether the Whisper model files are present (so callers can fail with a

crates/fleischwolf-pdf/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ flate2 = "1"
2121
tar = "0.4"
2222
regex = "1.11"
2323
lopdf = "0.34"
24+
fast_image_resize = "5.1.4"
2425

2526
[lib]
2627
name = "fleischwolf_pdf"

0 commit comments

Comments
 (0)