perf(nlp): run the spaCy pipeline once per distinct text - #4449
Conversation
There was a problem hiding this comment.
1 issue found across 4 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="unstructured/nlp/tokenize.py">
<violation number="1" location="unstructured/nlp/tokenize.py:153">
P2: When identical text arrives concurrently, `@lru_cache` lets each simultaneous miss run `_run_pipeline`, losing the one-pipeline-per-text optimization. Add per-key single-flight synchronization for threaded callers.</violation>
</file>
Shadow auto-approve: would not auto-approve because issues were found.
Re-trigger cubic
| return _load_spacy_model() | ||
|
|
||
|
|
||
| @lru_cache(maxsize=CACHE_MAX_SIZE) |
There was a problem hiding this comment.
P2: When identical text arrives concurrently, @lru_cache lets each simultaneous miss run _run_pipeline, losing the one-pipeline-per-text optimization. Add per-key single-flight synchronization for threaded callers.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At unstructured/nlp/tokenize.py, line 153:
<comment>When identical text arrives concurrently, `@lru_cache` lets each simultaneous miss run `_run_pipeline`, losing the one-pipeline-per-text optimization. Add per-key single-flight synchronization for threaded callers.</comment>
<file context>
@@ -148,10 +150,31 @@ def _get_nlp() -> spacy.language.Language:
return _load_spacy_model()
+@lru_cache(maxsize=CACHE_MAX_SIZE)
+def _process_cached(text: str) -> spacy.tokens.Doc:
+ """Memoized `_run_pipeline`, keyed on the text.
</file context>
|
Both addressed or answered in 468ec3b. P2, P2, |
There was a problem hiding this comment.
0 issues found across 2 files (changes from recent commits).
Shadow auto-approve: would not auto-approve. Auto-approval blocked by 1 unresolved issue from previous reviews.
Re-trigger cubic
`_process()` was the only expensive call in `unstructured.nlp.tokenize` and the only one not cached. `word_tokenize()`, `pos_tag()` and `sent_tokenize()` each memoize their own extracted view, which hides the repetition from their callers but not from spaCy -- every helper paid for its own identical pipeline run. `is_possible_narrative_text()` reaches all three while classifying a single element, so partitioning ran spaCy three times over the same string. Memoize the Doc and share it. Texts over 8 KiB skip the cache: a Doc is far heavier than the token lists the other caches hold, and `_process()` accepts input up to spaCy's 1M-char limit, so one oversized element could otherwise pin an outsized object for the life of the process. partition_html on 400 paragraphs: 1127ms -> 395ms. partition_text is the same path and improves identically.
`len(text)` counts characters, so calling the bound "8 KiB" understated it for non-ASCII input -- 8,192 CJK characters is roughly 24 KB of UTF-8. Characters are the right unit here anyway: `Doc` size tracks token count. Wording only.
468ec3b to
bdc90f7
Compare
Problem
_process()is the only expensive call inunstructured/nlp/tokenize.pyand the only one without a cache.word_tokenize(),pos_tag()andsent_tokenize()each memoize their own extracted view, which hides the repetition from their callers but not from spaCy.is_possible_narrative_text()reaches all three while classifying a single element:Three identical
Docs per element. On a 400-paragraph document that is 88% ofpartition_html()(is_possible_narrative_text1.60s of 1.81s in cProfile).Fix
Memoize the
Docso the three extractors share one pipeline run.Texts over 8 KiB bypass the cache. A
Docis much heavier than the token lists the existing caches hold (~8 KB serialised for a 68-char paragraph) and_process()accepts input up to spaCy's 1M-char limit, so one oversized element could otherwise pin an outsized object for the life of the process.All three consumers only read from the
Docand each returns a fresh list/tuple, so sharing is safe._process()has no callers outside this module.Numbers
partition_html, 400<p>partition_text, 400 paragraphsSame shared classification path, so this is not HTML-specific. Scaling was already linear and stays linear — this is a constant-factor change. The test suite itself drops 38.2s to 33.0s.
Tests
Four tests in
test_unstructured/nlp/test_tokenize.py, counting entries into spaCy below every cache in the module rather than timing anything. With the source change reverted on this branch:Suite before 146 failed / 2531 passed, after 146 failed / 2533 passed — the same 146 IDs, all pre-existing here (pandoc and the ML extras aren't installed locally). 14 modules that can't be collected without those extras were excluded from both runs.