-
Notifications
You must be signed in to change notification settings - Fork 1.3k
perf: avoid spaCy statistical pipeline for word tokenization #4408
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
KRRT7
wants to merge
7
commits into
Unstructured-IO:main
Choose a base branch
from
KRRT7:perf-tokenizer-only
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+419
−10
Open
Changes from 4 commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
3a88532
perf: tokenize words without running spaCy pipeline
KRRT7 ab5e888
perf: add text partition benchmark
KRRT7 efe77c7
test: cover tokenizer-only behavior
KRRT7 722584c
docs: add tokenizer performance changelog
KRRT7 956fadb
build(version): bump to 0.25.2-dev0
KRRT7 7fb89c0
fix benchmark aggregate median
KRRT7 8e06546
fix parallel metrics evaluation test
KRRT7 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
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
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,198 @@ | ||
| #!/usr/bin/env python3 | ||
| """Benchmark text-heavy partition paths while excluding NLP result-cache hits. | ||
|
|
||
| This benchmark is aimed at changes to the text-classification path. It keeps the | ||
| spaCy model loaded, but clears cached tokenization/classification results before | ||
| each iteration. This models processing new document text without folding model | ||
| startup time into every sample. | ||
|
|
||
| The output fingerprint covers every element's concrete type and text. Pass a | ||
| baseline result with ``--compare`` to verify exact output parity and report the | ||
| speedup. | ||
|
|
||
| Examples: | ||
| # Run on upstream/main, then on the candidate branch. | ||
| uv run --no-sync python scripts/performance/benchmark_text_partition.py /tmp/main.json | ||
| uv run --no-sync python scripts/performance/benchmark_text_partition.py \ | ||
| /tmp/candidate.json --compare /tmp/main.json | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import argparse | ||
| import hashlib | ||
| import json | ||
| import os | ||
| import statistics | ||
| import time | ||
| from collections import Counter | ||
| from pathlib import Path | ||
| from typing import Any, Sequence | ||
|
|
||
| from unstructured.documents.elements import Element | ||
| from unstructured.nlp import tokenize | ||
| from unstructured.partition.auto import partition | ||
|
|
||
| REPO_ROOT = Path(__file__).resolve().parent.parent.parent | ||
| DEFAULT_INPUTS = ( | ||
| REPO_ROOT / "example-docs/book-war-and-peace-1225p.txt", | ||
| REPO_ROOT / "example-docs/example-10k-230p.html", | ||
| REPO_ROOT / "example-docs/handbook-872p.docx", | ||
| REPO_ROOT / "example-docs/science-exploration-369p.pptx", | ||
| ) | ||
| DEFAULT_WARMUP_INPUT = REPO_ROOT / "example-docs/book-war-and-peace-1p.txt" | ||
|
|
||
|
|
||
| def clear_nlp_result_caches() -> None: | ||
| """Clear per-text results without unloading the spaCy model.""" | ||
| tokenize.word_tokenize.cache_clear() | ||
| tokenize.pos_tag.cache_clear() | ||
| tokenize._tokenize_for_cache.cache_clear() | ||
|
|
||
|
|
||
| def fingerprint(elements: Sequence[Element]) -> tuple[str, dict[str, int]]: | ||
| """Return a stable digest and type counts for exact classification/text output.""" | ||
| output = [(type(element).__name__, element.text) for element in elements] | ||
| encoded = json.dumps(output, ensure_ascii=False, separators=(",", ":")).encode() | ||
| type_counts = dict(sorted(Counter(element_type for element_type, _ in output).items())) | ||
| return hashlib.sha256(encoded).hexdigest(), type_counts | ||
|
|
||
|
|
||
| def display_path(path: Path) -> str: | ||
| """Return a repository-relative path when possible.""" | ||
| try: | ||
| return str(path.relative_to(REPO_ROOT)) | ||
| except ValueError: | ||
| return str(path) | ||
|
|
||
|
|
||
| def run_document(input_path: Path, iterations: int) -> dict[str, Any]: | ||
| """Measure one document and ensure its output is stable across iterations.""" | ||
| samples: list[float] = [] | ||
| expected_fingerprint: str | None = None | ||
| element_count = 0 | ||
| type_counts: dict[str, int] = {} | ||
|
|
||
| for _ in range(iterations): | ||
| clear_nlp_result_caches() | ||
| started = time.perf_counter() | ||
| elements = partition(filename=str(input_path), strategy="fast") | ||
| samples.append(time.perf_counter() - started) | ||
|
|
||
| output_fingerprint, current_type_counts = fingerprint(elements) | ||
| if expected_fingerprint is not None and output_fingerprint != expected_fingerprint: | ||
| raise RuntimeError("partition output changed between benchmark iterations") | ||
| expected_fingerprint = output_fingerprint | ||
| element_count = len(elements) | ||
| type_counts = current_type_counts | ||
|
|
||
| return { | ||
| "seconds": { | ||
| "samples": [round(sample, 6) for sample in samples], | ||
| "median": round(statistics.median(samples), 6), | ||
| "mean": round(statistics.fmean(samples), 6), | ||
| "min": round(min(samples), 6), | ||
| "max": round(max(samples), 6), | ||
| }, | ||
| "output": { | ||
| "sha256": expected_fingerprint, | ||
| "element_count": element_count, | ||
| "type_counts": type_counts, | ||
| }, | ||
| } | ||
|
|
||
|
|
||
| def run_benchmark( | ||
| input_paths: Sequence[Path], warmup_path: Path, iterations: int | ||
| ) -> dict[str, Any]: | ||
| """Warm the model once, then benchmark every document in the representative matrix.""" | ||
| partition(filename=str(warmup_path), strategy="fast") | ||
| clear_nlp_result_caches() | ||
|
|
||
| documents = { | ||
| display_path(input_path): run_document(input_path, iterations) for input_path in input_paths | ||
| } | ||
| median_total = sum(document["seconds"]["median"] for document in documents.values()) | ||
| return { | ||
| "iterations": iterations, | ||
| "documents": documents, | ||
| "summary": {"median_total_seconds": round(median_total, 6)}, | ||
| } | ||
|
|
||
|
|
||
| def compare_results(current: dict[str, Any], baseline_path: Path) -> None: | ||
| """Verify every document's output and print per-document and aggregate speedups.""" | ||
| baseline = json.loads(baseline_path.read_text()) | ||
| baseline_documents = baseline.get("documents", {}) | ||
| current_documents = current["documents"] | ||
| if current_documents.keys() != baseline_documents.keys(): | ||
| raise SystemExit("ERROR: candidate and baseline document sets differ") | ||
|
|
||
| print("\nComparison:") | ||
| for name, document in current_documents.items(): | ||
| baseline_document = baseline_documents[name] | ||
| if document["output"] != baseline_document.get("output"): | ||
| raise SystemExit(f"ERROR: candidate output differs for {name}") | ||
|
|
||
| baseline_median = baseline_document["seconds"]["median"] | ||
| current_median = document["seconds"]["median"] | ||
| speedup = baseline_median / current_median | ||
| improvement = (baseline_median - current_median) / baseline_median * 100 | ||
| print(f" {name}") | ||
| print(f" exact output; {baseline_median:.3f}s -> {current_median:.3f}s") | ||
| print(f" {speedup:.2f}x ({improvement:.1f}% faster)") | ||
|
|
||
| baseline_total = baseline["summary"]["median_total_seconds"] | ||
| current_total = current["summary"]["median_total_seconds"] | ||
| speedup = baseline_total / current_total | ||
| improvement = (baseline_total - current_total) / baseline_total * 100 | ||
| print(f" Aggregate median: {baseline_total:.3f}s -> {current_total:.3f}s") | ||
| print(f" Aggregate speedup: {speedup:.2f}x ({improvement:.1f}% faster)") | ||
|
|
||
|
|
||
| def parse_args() -> argparse.Namespace: | ||
| parser = argparse.ArgumentParser(description=__doc__) | ||
| parser.add_argument("output", type=Path, help="JSON file to receive benchmark results") | ||
| parser.add_argument( | ||
| "--input", | ||
| action="append", | ||
| dest="inputs", | ||
| type=Path, | ||
| help="document to benchmark; repeat to override the default matrix", | ||
| ) | ||
| parser.add_argument("--warmup-input", type=Path, default=DEFAULT_WARMUP_INPUT) | ||
| parser.add_argument( | ||
| "--iterations", type=int, default=int(os.environ.get("NUM_ITERATIONS", "3")) | ||
| ) | ||
| parser.add_argument("--compare", type=Path, help="baseline JSON to check and compare against") | ||
| args = parser.parse_args() | ||
| if args.iterations < 1: | ||
| parser.error("--iterations must be at least 1") | ||
| args.inputs = args.inputs or list(DEFAULT_INPUTS) | ||
| for path_arg in (*args.inputs, args.warmup_input): | ||
| if not path_arg.is_file(): | ||
| parser.error(f"input file does not exist: {path_arg}") | ||
| return args | ||
|
|
||
|
|
||
| def main() -> None: | ||
| args = parse_args() | ||
| results = run_benchmark( | ||
| [input_path.resolve() for input_path in args.inputs], | ||
| args.warmup_input.resolve(), | ||
| args.iterations, | ||
| ) | ||
| args.output.parent.mkdir(parents=True, exist_ok=True) | ||
| args.output.write_text(json.dumps(results, indent=2) + "\n") | ||
|
|
||
| for name, document in results["documents"].items(): | ||
| print(f"{name}: {document['seconds']['median']:.3f}s median") | ||
| print(f" {document['output']['element_count']} elements") | ||
| print(f"Aggregate median: {results['summary']['median_total_seconds']:.3f}s") | ||
| print(f"Results: {args.output}") | ||
| if args.compare: | ||
| compare_results(results, args.compare) | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| main() | ||
87 changes: 87 additions & 0 deletions
87
test_unstructured/benchmarks/test_benchmark_text_partition.py
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,87 @@ | ||
| import json | ||
|
|
||
| import pytest | ||
|
|
||
| from scripts.performance import benchmark_text_partition as benchmark | ||
| from unstructured.documents.elements import Text, Title | ||
|
|
||
|
|
||
| def test_fingerprint_captures_element_types_and_text(): | ||
| digest, type_counts = benchmark.fingerprint([Title("Heading"), Text("Body")]) | ||
|
|
||
| assert digest == "e9093cf43e539c4b37dbf00776d267318e9041c8de09e216e82e1c17a099f073" | ||
| assert type_counts == {"Text": 1, "Title": 1} | ||
|
|
||
|
|
||
| def test_run_benchmark_measures_each_document_without_nlp_result_cache_hits(monkeypatch, tmp_path): | ||
| warmup = tmp_path / "warmup.txt" | ||
| inputs = [tmp_path / "document.html", tmp_path / "document.docx"] | ||
| partitioned_paths = [] | ||
| cache_clears = [] | ||
|
|
||
| def fake_partition(*, filename, strategy): | ||
| assert strategy == "fast" | ||
| partitioned_paths.append(filename) | ||
| return [Text(text=filename)] | ||
|
|
||
| monkeypatch.setattr(benchmark, "partition", fake_partition) | ||
| monkeypatch.setattr(benchmark, "clear_nlp_result_caches", lambda: cache_clears.append(True)) | ||
|
|
||
| results = benchmark.run_benchmark(inputs, warmup, iterations=2) | ||
|
|
||
| assert partitioned_paths == [ | ||
| str(warmup), | ||
| str(inputs[0]), | ||
| str(inputs[0]), | ||
| str(inputs[1]), | ||
| str(inputs[1]), | ||
| ] | ||
| assert len(cache_clears) == 5 | ||
| assert list(results["documents"]) == [str(path) for path in inputs] | ||
| assert results["documents"][str(inputs[0])]["output"]["element_count"] == 1 | ||
|
|
||
|
|
||
| def test_compare_results_checks_each_document_output(tmp_path, capsys): | ||
| output = {"sha256": "same", "element_count": 1, "type_counts": {"Text": 1}} | ||
| baseline = { | ||
| "documents": {"document.txt": {"seconds": {"median": 2.0}, "output": output}}, | ||
| "summary": {"median_total_seconds": 2.0}, | ||
| } | ||
| current = { | ||
| "documents": {"document.txt": {"seconds": {"median": 1.0}, "output": output}}, | ||
| "summary": {"median_total_seconds": 1.0}, | ||
| } | ||
| baseline_path = tmp_path / "baseline.json" | ||
| baseline_path.write_text(json.dumps(baseline)) | ||
|
|
||
| benchmark.compare_results(current, baseline_path) | ||
|
|
||
| stdout = capsys.readouterr().out | ||
| assert "exact output; 2.000s -> 1.000s" in stdout | ||
| assert "Aggregate speedup: 2.00x (50.0% faster)" in stdout | ||
|
|
||
|
|
||
| def test_compare_results_rejects_output_regression(tmp_path): | ||
| baseline = { | ||
| "documents": { | ||
| "document.txt": { | ||
| "seconds": {"median": 2.0}, | ||
| "output": {"sha256": "baseline"}, | ||
| } | ||
| }, | ||
| "summary": {"median_total_seconds": 2.0}, | ||
| } | ||
| current = { | ||
| "documents": { | ||
| "document.txt": { | ||
| "seconds": {"median": 1.0}, | ||
| "output": {"sha256": "changed"}, | ||
| } | ||
| }, | ||
| "summary": {"median_total_seconds": 1.0}, | ||
| } | ||
| baseline_path = tmp_path / "baseline.json" | ||
| baseline_path.write_text(json.dumps(baseline)) | ||
|
|
||
| with pytest.raises(SystemExit, match="candidate output differs for document.txt"): | ||
| benchmark.compare_results(current, baseline_path) |
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
Oops, something went wrong.
Oops, something went wrong.
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.
Uh oh!
There was an error while loading. Please reload this page.