Parse speed round 4: 1.56× default / 1.74× single-thread, output-identical - #9
Merged
Merged
Conversation
…rd, prefilter v3 Output-identical speed work on both halves of the pipeline (all verified hash-identical across a 36-doc corpus: XHTML, all/json/html renders, block texts; plus 5.3k-page token-stream differentials and fuzzing): Front-end: - fast_content_parser.py: single-pass content-stream tokenizer feeding pdfminer's unmodified stack machine; replicates PSBaseParser's exact token semantics (incl. the chunk-edge \CR LF artifact, lazy octal overflow, EOF flush quirks); operator-dispatch cache in execute(). - _CharDictDevice: PDFPageAggregator that emits warp char dicts straight from render_string_horizontal with per-run constants hoisted; no more per-glyph LTChar objects, layout-tree aggregation, or leaf walk. Engine: - sent_tokenize prefilter v3: per-text chunk-candidate sets prune the abbreviation rules' surviving regex scans ~30x (word_exact chunk sets + word_dotted anchor-offset checks, casefold-alignment guarded). - get_class exact-hit shortcut; digit-count short-circuit in indent parser; parse_line page-word flag + frozenset punctuation tables + reused token list; hoisted BS4 .text walks in both parse phases. Cross-cutting: - gc_guard.py: reentrant parse-scoped GC guard (defer gen-2 collections during a parse, one full collect at outermost exit) — full-heap scans were ~15-30% of wall time on a warm heap. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011CDBNBgmXe7grRqvpSu5js
… regex - gc_guard: gate the outermost-exit full collection on the pending gen-2 counter crossing the saved threshold, making the guard a pure rescheduler — a small parse that would not have triggered a full collection no longer pays for one (the unconditional collect was doubling small-doc engine time and slowing parallel FE stripes). - parse_line: loop-local counters/flags written back after the token loop, one .lower() per token reused by the state/numbered/page checks, and the noun-chunk-ending join skipped when the ending-token set is empty (it always is for engine bare lines). - _rule_candidates: dotted-rule anchors located by a compiled chunk-start character-class regex ((?<!\S)[...]) so the Python-level anchor loop only visits relevant positions; exact-candidate chunks come from str.split. All verified output-identical: unit suites plus the 6-doc quick corpus (hashes of XHTML and all renders unchanged). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011CDBNBgmXe7grRqvpSu5js
…AUDE.md Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011CDBNBgmXe7grRqvpSu5js
The persistent spawn pool makes per-document startup cheap, so 3-6 page documents gain ~25% front-end wall time from striping (parallel-path byte-identity re-verified on the small contracts); 1-2 page documents stay serial. PERF_OPTIMIZATION.md now carries the interleaved baseline/current A/B: median 22.7 -> 35.5 pg/s default workers (1.56x, worst pair 1.54x) and 11.1 -> 19.3 pg/s single-thread (1.74x); FE 1.90x, engine 1.34x. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011CDBNBgmXe7grRqvpSu5js
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011CDBNBgmXe7grRqvpSu5js
The fast front-end replicates three small pdfminer surfaces (tokenizer semantics, LTChar bbox math, the execute() body); a pdfminer upgrade could change the originals while the copies fossilize, with the runtime fallback chain hiding the drift. tests/test_fast_content_parser.py makes that impossible to miss on an upgrade PR: - Equivalence tests with pdfminer's live code as the oracle: token-stream equality (FastContentParser vs PDFContentParser, values + positions), char/line/rect page-object equality (char-dict device vs layout-tree walk) over three diverse fixtures, and a 500-case deterministic fuzz of the tokenizer's edge branches incl. the 4096-chunk-edge \CR LF cases. - Source pins: sha256 of inspect.getsource() for every vendored upstream surface (execute, LTChar.__init__, render_string*, render_char, the _parse_* family, the matrix helpers), plus direct assertions on the tokenizer's regex classes/escape table, with a failure message that spells out the re-sync procedure. 30 tests, ~3s, always-on (not @slow) so dependabot bumps run them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011CDBNBgmXe7grRqvpSu5js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description of the change
Makes the whole PDF parse 1.5×+ faster with byte-identical output, via systematic architectural changes to both halves of the pipeline. Measured on the standard timing harness (dense set, no-OCR, 3-run warm median, 4-CPU box, Python 3.11). The default-workers before/after was measured as three interleaved baseline/current pairs (baseline
main@ bd76cc6 in a git worktree, alternating with this branch in one session) so box noise can't flatter either side — pairwise ratios 1.61× / 1.54× / 1.58×:main@ bd76cc6, default workers (median of 3)main@ bd76cc6, workers=1Per-half medians (default workers): front-end 1.90×, engine 1.34×.
The levers (all output-identical)
file_parser/fast_content_parser.py) — pdfminer lexes content streams through a per-chunk state machine that was ~half of front-end time.FastContentParserproduces the same(pos, token)stream in one pass over the concatenated stream bytes and feeds pdfminer's unmodified stack machine (nextobject,do_keyword, BI/ID/EI inline images). Deliberately replicated quirks: the\CR LF-at-chunk-edge string-escape artifact (original per-stream 4096-byte chunk geometry reconstructed), the lazy octal-overflowAssertionError(poisoned token tail, so inline-image bytes the original never lexes can't crash the eager scan), one-shot EOF flush drops, silent number/keyword drops, and the stream-boundary whitespace flush (pdfminer #1157).FastPageInterpreter.executeadds an interned-keyword operator-dispatch cache._CharDictDevice) — aPDFPageAggregatorwhoserender_string_horizontalemits warp's char dicts directly, withLTChar.__init__'s exact bbox math inlined (same float ops and association throughapply_matrix_rect's corner expressions) and per-text-run constants hoisted. No per-glyphLTChar, no layout-tree aggregation, no leaf walk. Rule lines/rects still come from the inherited, unmodifiedpaint_path. Fallback chain preserved and tested under forced failure: device → layout-tree fast path → pdfplumber → OCR routing (all three produce identical XHTML).ingestor_utils/gc_guard.py) — a parse allocates millions of containers, so CPython's full (gen-2) collections fire repeatedly inside every parse and scan the whole heap: measured 15–30% of engine wall time on a warm process. The reentrant, thread-safe guard raises the gen-2 threshold for the parse and, at outermost exit, runs one full collection only if the pending gen-2 counter crossed the stock threshold — i.e., it purely reschedules the same collections to parse boundaries (a small parse that wouldn't have triggered one doesn't pay for one). Young-generation collection continues throughout, so memory stays bounded.WARP_GC_GUARD=0disables.sent_tokenizeprefilter v3 — the ~350 abbreviation-protection rules ran ~27k surviving regex subs per S-1 exhibit though only ~3% fire. One pass per text now builds chunk-candidate sets — for purely-alphanumeric abbs,\s{abb}.\scan only match a whitespace-delimited chunk equal toabborabb+c(\s≡str.isspaceverified over all of Unicode); dotted abbs (u.s) are verified by literal-run offsets at chunk-start anchors, guarded on casefold length-alignment. Sub scans drop ~30×. Soundness is unit-tested per rule shape, plus a differential against a replica of the old loop over 1.3k corpus texts.WARP_FE_PARALLEL_MIN_PAGESdefault 8 → 3: the persistent spawn pool makes per-document startup cheap, so 3–6 page docs gain ~25% front-end wall time from striping (parallel-path byte-identity re-verified on the small contracts); 1–2 page docs stay serial.get_classexact-hit dict shortcut (provably identical: keys append-only, insertion-ordered scan);parse_linehot loop with loop-local counters, one casefold per token, frozenset punctuation tables, a tracked has-page-word flag (kills a 38k-call genexpr), and the noun-chunk-ending join skipped when the ending-token set is empty; digit-count short-circuit inindent_parser; one BS4.textwalk per<p>per phase.How output-identity was verified
allrender's html+jsonfile_data, and block texts — dense benchmark set + FortWorth contracts + 20 hetero-100 enterprise pages — identical before/after every change.FastContentParservs pdfminer'sPDFContentParseron every content stream of every fixture PDF — 5,338 pages / 42M tokens, values and positions, zero mismatches — plus ~12k adversarial fuzz cases over the tokenizer's edge branches (nested/escaped strings, chunk-edge\CR LF, octal overflows, hexstrings, literal hex escapes, EOF states).--runslow735 passed / 1 failed — the one failure wastest_min_pages_env_parsingasserting the old parallel-gate default of 8, updated for the intentional 8→3 change (that test file re-run green after). All committed baselines (S-1 cross-engine, OC export/batch, docsling, hetero-100, legal-100, semantic units) are untouched and pass unchanged — as expected, since the parse output is byte-identical.pdfminer upgrade guards (committed as
tests/test_fast_content_parser.py)The fast paths replicate three small pdfminer surfaces (tokenizer semantics,
LTCharbbox math, theexecute()body), which could silently fossilize when a pdfminer bump changes the originals. Two always-on test layers (~3s) turn that into a red X on the upgrade PR instead:PDFContentParser, char/line/rect page-object equality vs the layout-tree walk, over three diverse fixtures, plus a 500-case deterministic tokenizer fuzz including the 4096-chunk-edge\CR LFcases.inspect.getsource()for every replicated upstream surface (execute,LTChar.__init__,render_string*,render_char, the_parse_*family, the matrix helpers) plus direct assertions on the tokenizer's regex classes and escape table — failing with a re-sync procedure whenever upstream edits code we vendored, even if behavior looks unchanged on our fixtures.Notes
benchmarks/PERF_OPTIMIZATION.mddocuments the round with methodology and the not-pursued options (native word extraction, lxml, deepcopy specialization).WARP_GC_GUARD).Type of change
Related issues
None — standalone performance round (follows the rounds documented in
benchmarks/PERF_OPTIMIZATION.md).Checklists
Development
make check)tests/test_fast_content_parser.py; prefilter soundness suites extended intests/test_sent_tokenizer.py; min-pages default assertion updated; correctness of the new front-end paths is additionally pinned by the existing end-to-end suites since output is byte-identical)--runslow735 passed with the single gate-default assertion failure, and that test file re-run green after the assertion update)Code review