chore: import upstream snapshot with attribution
CI / lint (3.11) (push) Has been cancelled
CI / lint (3.12) (push) Has been cancelled
CI / lint (3.13) (push) Has been cancelled
CI / shellcheck (push) Has been cancelled
CI / shfmt (push) Has been cancelled
CI / setup (3.11) (push) Has been cancelled
CI / setup (3.12) (push) Has been cancelled
CI / setup (3.13) (push) Has been cancelled
CI / check-licenses (3.12) (push) Has been cancelled
CI / test_unit (3.11) (push) Has been cancelled
CI / test_unit (3.12) (push) Has been cancelled
CI / test_unit (3.13) (push) Has been cancelled
CI / test_unit_no_extras (3.11) (push) Has been cancelled
CI / test_unit_no_extras (3.12) (push) Has been cancelled
CI / test_json_to_html (3.12) (push) Has been cancelled
CI / test_unit_no_extras (3.13) (push) Has been cancelled
CI / test_unit_dependency_extras (csv, 3.12, --extra csv) (push) Has been cancelled
CI / test_unit_dependency_extras (xlsx, 3.11, --extra xlsx) (push) Has been cancelled
CI / test_unit_dependency_extras (xlsx, 3.12, --extra xlsx) (push) Has been cancelled
CI / test_unit_dependency_extras (csv, 3.11, --extra csv) (push) Has been cancelled
CI / test_unit_dependency_extras (csv, 3.13, --extra csv) (push) Has been cancelled
CI / test_unit_dependency_extras (docx, 3.11, --extra docx) (push) Has been cancelled
CI / test_unit_dependency_extras (docx, 3.12, --extra docx) (push) Has been cancelled
CI / test_unit_dependency_extras (docx, 3.13, --extra docx) (push) Has been cancelled
CI / test_unit_dependency_extras (markdown, 3.11, --extra md) (push) Has been cancelled
CI / test_unit_dependency_extras (markdown, 3.12, --extra md) (push) Has been cancelled
CI / test_unit_dependency_extras (markdown, 3.13, --extra md) (push) Has been cancelled
CI / test_unit_dependency_extras (odt, 3.11, --extra odt) (push) Has been cancelled
CI / test_unit_dependency_extras (odt, 3.12, --extra odt) (push) Has been cancelled
CI / test_unit_dependency_extras (odt, 3.13, --extra odt) (push) Has been cancelled
CI / test_unit_dependency_extras (pdf-image, 3.11, --extra pdf --extra image --extra paddleocr) (push) Has been cancelled
CI / test_unit_dependency_extras (pdf-image, 3.12, --extra pdf --extra image --extra paddleocr) (push) Has been cancelled
CI / test_unit_dependency_extras (pdf-image, 3.13, --extra pdf --extra image --extra paddleocr) (push) Has been cancelled
CI / test_unit_dependency_extras (pptx, 3.11, --extra pptx) (push) Has been cancelled
CI / test_unit_dependency_extras (pptx, 3.12, --extra pptx) (push) Has been cancelled
CI / test_unit_dependency_extras (pptx, 3.13, --extra pptx) (push) Has been cancelled
CI / test_unit_dependency_extras (pypandoc, 3.11, --extra epub --extra org --extra rtf --extra rst) (push) Has been cancelled
CI / test_unit_dependency_extras (pypandoc, 3.12, --extra epub --extra org --extra rtf --extra rst) (push) Has been cancelled
CI / test_unit_dependency_extras (pypandoc, 3.13, --extra epub --extra org --extra rtf --extra rst) (push) Has been cancelled
Build And Push Docker Image / set-short-sha (push) Has been cancelled
Partition Benchmark / setup (push) Has been cancelled
Partition Benchmark / Measure and compare partition() runtime (push) Has been cancelled
CI / test_unit_dependency_extras (xlsx, 3.13, --extra xlsx) (push) Has been cancelled
CI / test_ingest_src (3.12) (push) Has been cancelled
CI / test_json_to_markdown (3.12) (push) Has been cancelled
CI / changelog (push) Has been cancelled
CI / test_dockerfile (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Build And Push Docker Image / build-images (linux/amd64, opensource-linux-8core) (push) Has been cancelled
Build And Push Docker Image / build-images (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build And Push Docker Image / publish-images (push) Has been cancelled
CI / lint (3.11) (push) Has been cancelled
CI / lint (3.12) (push) Has been cancelled
CI / lint (3.13) (push) Has been cancelled
CI / shellcheck (push) Has been cancelled
CI / shfmt (push) Has been cancelled
CI / setup (3.11) (push) Has been cancelled
CI / setup (3.12) (push) Has been cancelled
CI / setup (3.13) (push) Has been cancelled
CI / check-licenses (3.12) (push) Has been cancelled
CI / test_unit (3.11) (push) Has been cancelled
CI / test_unit (3.12) (push) Has been cancelled
CI / test_unit (3.13) (push) Has been cancelled
CI / test_unit_no_extras (3.11) (push) Has been cancelled
CI / test_unit_no_extras (3.12) (push) Has been cancelled
CI / test_json_to_html (3.12) (push) Has been cancelled
CI / test_unit_no_extras (3.13) (push) Has been cancelled
CI / test_unit_dependency_extras (csv, 3.12, --extra csv) (push) Has been cancelled
CI / test_unit_dependency_extras (xlsx, 3.11, --extra xlsx) (push) Has been cancelled
CI / test_unit_dependency_extras (xlsx, 3.12, --extra xlsx) (push) Has been cancelled
CI / test_unit_dependency_extras (csv, 3.11, --extra csv) (push) Has been cancelled
CI / test_unit_dependency_extras (csv, 3.13, --extra csv) (push) Has been cancelled
CI / test_unit_dependency_extras (docx, 3.11, --extra docx) (push) Has been cancelled
CI / test_unit_dependency_extras (docx, 3.12, --extra docx) (push) Has been cancelled
CI / test_unit_dependency_extras (docx, 3.13, --extra docx) (push) Has been cancelled
CI / test_unit_dependency_extras (markdown, 3.11, --extra md) (push) Has been cancelled
CI / test_unit_dependency_extras (markdown, 3.12, --extra md) (push) Has been cancelled
CI / test_unit_dependency_extras (markdown, 3.13, --extra md) (push) Has been cancelled
CI / test_unit_dependency_extras (odt, 3.11, --extra odt) (push) Has been cancelled
CI / test_unit_dependency_extras (odt, 3.12, --extra odt) (push) Has been cancelled
CI / test_unit_dependency_extras (odt, 3.13, --extra odt) (push) Has been cancelled
CI / test_unit_dependency_extras (pdf-image, 3.11, --extra pdf --extra image --extra paddleocr) (push) Has been cancelled
CI / test_unit_dependency_extras (pdf-image, 3.12, --extra pdf --extra image --extra paddleocr) (push) Has been cancelled
CI / test_unit_dependency_extras (pdf-image, 3.13, --extra pdf --extra image --extra paddleocr) (push) Has been cancelled
CI / test_unit_dependency_extras (pptx, 3.11, --extra pptx) (push) Has been cancelled
CI / test_unit_dependency_extras (pptx, 3.12, --extra pptx) (push) Has been cancelled
CI / test_unit_dependency_extras (pptx, 3.13, --extra pptx) (push) Has been cancelled
CI / test_unit_dependency_extras (pypandoc, 3.11, --extra epub --extra org --extra rtf --extra rst) (push) Has been cancelled
CI / test_unit_dependency_extras (pypandoc, 3.12, --extra epub --extra org --extra rtf --extra rst) (push) Has been cancelled
CI / test_unit_dependency_extras (pypandoc, 3.13, --extra epub --extra org --extra rtf --extra rst) (push) Has been cancelled
Build And Push Docker Image / set-short-sha (push) Has been cancelled
Partition Benchmark / setup (push) Has been cancelled
Partition Benchmark / Measure and compare partition() runtime (push) Has been cancelled
CI / test_unit_dependency_extras (xlsx, 3.13, --extra xlsx) (push) Has been cancelled
CI / test_ingest_src (3.12) (push) Has been cancelled
CI / test_json_to_markdown (3.12) (push) Has been cancelled
CI / changelog (push) Has been cancelled
CI / test_dockerfile (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Build And Push Docker Image / build-images (linux/amd64, opensource-linux-8core) (push) Has been cancelled
Build And Push Docker Image / build-images (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build And Push Docker Image / publish-images (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
benchmark_results
|
||||
profile_results
|
||||
@@ -0,0 +1,45 @@
|
||||
# Performance
|
||||
This is a collection of tools helpful for inspecting and tracking performance of the Unstructured library.
|
||||
|
||||
The benchmarking script allows a user to track performance time to partitioning results against a fixed set of test documents and store those results with indication of architecture, instance type, and git hash, in S3.
|
||||
|
||||
The profiling script allows a user to inspect how time time and memory are spent across called functions when performing partitioning on a given document.
|
||||
|
||||
## Install
|
||||
Benchmarking requires no additional dependencies and should work without any initial setup.
|
||||
Profiling has a few dependencies which can be installed with:
|
||||
|
||||
```bash
|
||||
pip install -r scripts/performance/requirements.txt
|
||||
npm install -g speedscope
|
||||
```
|
||||
|
||||
The second dependency `speedscope` provides a tool to view profiling results from `py-spy` locally. Alternatively you can also drop the profile result `*.speedscope` into https://www.speedscope.app/ to view the results online.
|
||||
|
||||
## Run
|
||||
### Benchmark
|
||||
Export / assign desired environment variable settings:
|
||||
- DOCKER_TEST: Set to true to run benchmark inside a Docker container (default: false)
|
||||
- NUM_ITERATIONS: Number of iterations for benchmark (e.g., 100) (default: 3)
|
||||
- INSTANCE_TYPE: Type of benchmark instance (e.g., "c5.xlarge") (default: unspecified)
|
||||
- PUBLISH_RESULTS: Set to true to publish results to S3 bucket (default: false)
|
||||
-
|
||||
Usage: `./scripts/performance/benchmark.sh`
|
||||
|
||||
### Profile
|
||||
|
||||
Export / assign desired environment variable settings:
|
||||
- DOCKER_TEST: Set to true to run profiling inside a Docker container (default: false)
|
||||
|
||||
Usage:
|
||||
|
||||
**on Linux**: `./scripts/performance/profile.sh`
|
||||
|
||||
**on macOS**: `sudo -E ./scripts/performance/profile.sh`; `py-spy` requires su to run on macOS
|
||||
|
||||
- Run the script and choose the profiling mode: 'run' or 'view'.
|
||||
- In the 'run' mode, you can profile custom files or select existing test files.
|
||||
- In the 'view' mode, you can view previously generated profiling results.
|
||||
- The script supports time profiling with cProfile and memory profiling with memray.
|
||||
- Users can choose different visualization options such as flamegraphs, tables, trees, summaries, and statistics.
|
||||
- Test documents are synced from an S3 bucket to a local directory before running the profiles
|
||||
Executable
+42
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# This is intended solely to be called by scripts/performance/benchmark.sh.
|
||||
# This file is separated out to allow us to easily execute this part of the test script inside a Docker container.
|
||||
|
||||
SCRIPT_DIR=$(dirname "$0")
|
||||
TEST_DOCS_FOLDER="$SCRIPT_DIR/docs"
|
||||
TIMEFORMAT="%R"
|
||||
|
||||
mkdir -p "$SCRIPT_DIR/benchmark_results" >/dev/null 2>&1
|
||||
DATE=$(date +"%Y-%m-%d_%H-%M-%S")
|
||||
RESULTS_FILE="$SCRIPT_DIR/benchmark_results/${DATE}_benchmark_results_${INSTANCE_TYPE}_$("$SCRIPT_DIR/get-stats-name.sh")_$GIT_HASH.csv"
|
||||
echo "Test File,Iterations,Average Execution Time (s)" >"$RESULTS_FILE"
|
||||
|
||||
echo "Starting benchmark test..."
|
||||
for file in "$TEST_DOCS_FOLDER"/*; do
|
||||
echo "Testing file: $(basename "$file")"
|
||||
|
||||
if [[ " ${SLOW_FILES[*]} " =~ $(basename "$file") ]]; then
|
||||
echo "File found in slow files list. Running once..."
|
||||
num_iterations=1
|
||||
else
|
||||
# shellcheck disable=SC2153
|
||||
num_iterations=$NUM_ITERATIONS
|
||||
fi
|
||||
|
||||
strategy="fast"
|
||||
if [[ " ${HI_RES_STRATEGY_FILES[*]} " =~ $(basename "$file") ]]; then
|
||||
echo "Testing with hi_res strategy"
|
||||
strategy="hi_res"
|
||||
fi
|
||||
if ! response=$(python3 -m "scripts.performance.time_partition" "$file" "$num_iterations" "$strategy"); then
|
||||
echo "error: $response"
|
||||
exit 1
|
||||
fi
|
||||
average_time=$(echo "$response" | awk '/Average time:/ {print $3}')
|
||||
echo "Average execution time: $average_time seconds"
|
||||
echo "$(basename "$file"),$num_iterations,$average_time" >>"$RESULTS_FILE"
|
||||
done
|
||||
|
||||
# NOTE: Be careful if updating this message. The benchmarking script looks for this message to get the CSV file name.
|
||||
echo "Benchmarking completed. Results saved to: $(basename "$RESULTS_FILE")"
|
||||
Executable
+67
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Usage:
|
||||
# - Set the required environment variables (listed below)
|
||||
# - Run the script: ./scripts/performance/benchmark.sh
|
||||
|
||||
# Environment Variables:
|
||||
# - DOCKER_TEST: Set to "true" to run benchmark inside a Docker container (default: false)
|
||||
# - NUM_ITERATIONS: Number of iterations for benchmark (e.g., 100) (default: 3)
|
||||
# - INSTANCE_TYPE: Type of benchmark instance (e.g., "c5.xlarge") (default: "unspecified")
|
||||
# - PUBLISH_RESULTS: Set to "true" to publish results to S3 bucket (default: false)
|
||||
|
||||
SLOW_FILES=("DA-619p.pdf" "layout-parser-paper-hi_res-16p.pdf" "layout-parser-paper-10p.jpg")
|
||||
HI_RES_STRATEGY_FILES=("layout-parser-paper-hi_res-16p.pdf")
|
||||
NUM_ITERATIONS=${NUM_ITERATIONS:-2}
|
||||
INSTANCE_TYPE=${INSTANCE_TYPE:-"unspecified"}
|
||||
|
||||
S3_BUCKET="utic-dev-tech-fixtures"
|
||||
S3_RESULTS_DIR="performance-test/results"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
GIT_HASH="$(git rev-parse --short HEAD)"
|
||||
|
||||
# Save the results filename to a temporary file
|
||||
RESULTS_FILENAME_FILE=$(mktemp)
|
||||
trap 'rm -f $RESULTS_FILENAME_FILE' EXIT
|
||||
|
||||
function read_benchmark_logs_for_results() {
|
||||
if [[ $line =~ Results\ saved\ to:\ ([^\ ]+) ]]; then
|
||||
results_filename="${BASH_REMATCH[1]}"
|
||||
echo "CSV file value found: $results_filename"
|
||||
echo "$results_filename" >"$RESULTS_FILENAME_FILE" # Store the value in the temporary file
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ "$DOCKER_TEST" == "true" ]]; then
|
||||
DOCKER_IMAGE=unstructured:perf-test make docker-build
|
||||
docker rm -f unstructured-perf-test >/dev/null 2>&1
|
||||
docker run \
|
||||
--name unstructured-perf-test \
|
||||
--rm \
|
||||
-e NUM_ITERATIONS="$NUM_ITERATIONS" \
|
||||
-e INSTANCE_TYPE="$INSTANCE_TYPE" \
|
||||
-e GIT_HASH="$GIT_HASH" \
|
||||
-e SLOW_FILES="${SLOW_FILES[*]}" \
|
||||
-e HI_RES_STRATEGY_FILES="${HI_RES_STRATEGY_FILES[*]}" \
|
||||
-v "${SCRIPT_DIR}":/home/notebook-user/scripts/performance \
|
||||
unstructured:perf-test \
|
||||
bash /home/notebook-user/scripts/performance/benchmark-local.sh 2>&1 | tee >(while IFS= read -r line; do
|
||||
read_benchmark_logs_for_results
|
||||
done)
|
||||
else
|
||||
NUM_ITERATIONS="$NUM_ITERATIONS" INSTANCE_TYPE="$INSTANCE_TYPE" GIT_HASH="$GIT_HASH" SLOW_FILES="${SLOW_FILES[*]}" HI_RES_STRATEGY_FILES="${HI_RES_STRATEGY_FILES[*]}" "$SCRIPT_DIR"/benchmark-local.sh 2>&1 |
|
||||
tee >(while IFS= read -r line; do
|
||||
read_benchmark_logs_for_results
|
||||
done)
|
||||
fi
|
||||
# Read the result filename from the temporary file
|
||||
results_filename=$(<"$RESULTS_FILENAME_FILE")
|
||||
if [[ -z $results_filename ]]; then
|
||||
echo "Error: Results filename value not found in the benchmark logs."
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$PUBLISH_RESULTS" == "true" ]]; then
|
||||
S3_RESULTS_PATH="$S3_BUCKET/$S3_RESULTS_DIR"
|
||||
echo "Publishing results to S3 bucket: $S3_RESULTS_PATH"
|
||||
aws s3 cp "$SCRIPT_DIR/benchmark_results/$results_filename" "s3://$S3_RESULTS_PATH/"
|
||||
fi
|
||||
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Measure partition() runtime over a fixed set of representative example-docs files.
|
||||
|
||||
Follows the same conventions as the existing scripts/performance tooling:
|
||||
- PDFs and images are run with strategy="hi_res".
|
||||
- Everything else is run with strategy="fast".
|
||||
- Each file is timed over NUM_ITERATIONS runs (after a warmup) and the
|
||||
average is recorded, matching time_partition.py behaviour.
|
||||
|
||||
Writes a JSON file mapping each file to its average runtime, plus a ``__total__``
|
||||
key with the wall-clock total. An optional positional argument sets the output
|
||||
path (default: scripts/performance/partition-speed-test/benchmark_results.json).
|
||||
|
||||
Also writes the total duration to $GITHUB_OUTPUT as ``duration=<seconds>``.
|
||||
|
||||
Usage:
|
||||
uv run --no-sync python scripts/performance/benchmark_partition.py [output.json]
|
||||
|
||||
Environment variables:
|
||||
NUM_ITERATIONS number of timed iterations per file (default: 1)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from unstructured.partition.auto import partition
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
BENCHMARK_FILES: list[tuple[str, str]] = [
|
||||
# PDFs - hi_res
|
||||
("example-docs/pdf/a1977-backus-p21.pdf", "hi_res"),
|
||||
("example-docs/pdf/copy-protected.pdf", "hi_res"),
|
||||
("example-docs/pdf/reliance.pdf", "hi_res"),
|
||||
("example-docs/pdf/pdf-with-ocr-text.pdf", "hi_res"),
|
||||
# Images - hi_res
|
||||
("example-docs/double-column-A.jpg", "hi_res"),
|
||||
("example-docs/double-column-B.jpg", "hi_res"),
|
||||
("example-docs/embedded-images-tables.jpg", "hi_res"),
|
||||
# Other document types - fast
|
||||
("example-docs/contains-pictures.docx", "fast"),
|
||||
("example-docs/example-10k-1p.html", "fast"),
|
||||
("example-docs/science-exploration-1p.pptx", "fast"),
|
||||
]
|
||||
|
||||
NUM_ITERATIONS: int = int(os.environ.get("NUM_ITERATIONS", "1"))
|
||||
|
||||
DEFAULT_OUTPUT = Path(__file__).parent / "partition-speed-test" / "benchmark_results.json"
|
||||
|
||||
|
||||
def _warmup(filepath: str) -> None:
|
||||
"""Run a single fast-strategy partition to warm the process up.
|
||||
|
||||
Mirrors warm_up_process() in time_partition.py: uses a warmup-docs/
|
||||
variant if present, otherwise falls back to the file itself.
|
||||
"""
|
||||
|
||||
warmup_dir = Path(__file__).parent / "warmup-docs"
|
||||
warmup_file = warmup_dir / f"warmup{Path(filepath).suffix}"
|
||||
target = str(warmup_file) if warmup_file.exists() else filepath
|
||||
partition(target, strategy="fast")
|
||||
|
||||
|
||||
def _measure(filepath: str, strategy: str, iterations: int) -> float:
|
||||
"""Return the average wall-clock seconds for partitioning *filepath*.
|
||||
|
||||
Identical logic to time_partition.measure_execution_time().
|
||||
"""
|
||||
|
||||
total = 0.0
|
||||
for _ in range(iterations):
|
||||
t0 = time.perf_counter()
|
||||
partition(filepath, strategy=strategy)
|
||||
total += time.perf_counter() - t0
|
||||
return total / iterations
|
||||
|
||||
|
||||
def _set_github_output(key: str, value: str) -> None:
|
||||
"""Write key=value to $GITHUB_OUTPUT when running in Actions."""
|
||||
gho = os.environ.get("GITHUB_OUTPUT")
|
||||
if gho:
|
||||
with open(gho, "a") as fh:
|
||||
fh.write(f"{key}={value}\n")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
output_path = Path(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_OUTPUT
|
||||
repo_root = Path(__file__).resolve().parent.parent.parent # scripts/performance/ -> repo root
|
||||
|
||||
logger.info("=" * 60)
|
||||
logger.info(f"Partition benchmark (NUM_ITERATIONS={NUM_ITERATIONS})")
|
||||
logger.info("=" * 60)
|
||||
|
||||
results: dict[str, float] = {}
|
||||
grand_start = time.perf_counter()
|
||||
|
||||
for rel_path, strategy in BENCHMARK_FILES:
|
||||
filepath = repo_root / rel_path
|
||||
if not filepath.exists():
|
||||
logger.warning(f" WARNING: {rel_path} not found – skipping.")
|
||||
continue
|
||||
|
||||
logger.info(f" {rel_path} (strategy={strategy}, iterations={NUM_ITERATIONS})")
|
||||
_warmup(str(filepath))
|
||||
avg = _measure(str(filepath), strategy, NUM_ITERATIONS)
|
||||
results[rel_path] = round(avg, 4)
|
||||
logger.info(f" avg {avg:.2f}s")
|
||||
|
||||
total_seconds = round(time.perf_counter() - grand_start, 2)
|
||||
results["__total__"] = total_seconds
|
||||
|
||||
logger.info(f"\nTotal wall-clock time: {total_seconds}s")
|
||||
|
||||
# Write JSON results file (consumed by compare_benchmark.py)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text(json.dumps(results, indent=2) + "\n")
|
||||
logger.info(f"Results written to {output_path}")
|
||||
|
||||
# Also expose total as a GitHub Actions step output
|
||||
_set_github_output("duration", str(int(total_seconds)))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,284 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compare this run's partition() benchmark against a rolling baseline.
|
||||
|
||||
Why a rolling baseline (and not a single stored "best"):
|
||||
GitHub's shared ``ubuntu-latest`` runners vary in speed by ~1.6x run-to-run,
|
||||
and that variance scales the whole benchmark (partition work *and* fixed
|
||||
overhead). A single all-time-minimum baseline is therefore unbeatable the
|
||||
moment one lucky-fast runner records it -- every later run on a normal runner
|
||||
exceeds best*(1+threshold) and the check fails for everyone. (That is exactly
|
||||
what happened: a frozen 81s "best" vs a real ~130s fleet.)
|
||||
|
||||
Instead we keep the last ``--window`` runs from ``main`` and compare against
|
||||
their **median**, which a single fast/slow outlier can't poison. The baseline
|
||||
tracks reality over time rather than ratcheting to an unrepeatable minimum.
|
||||
|
||||
Storage:
|
||||
Each ``main`` run is one immutable JSON record under ``HISTORY_DIR`` (synced
|
||||
to/from S3 by the workflow). This script never talks to S3 -- it only reads
|
||||
the local history dir and, with ``--record``, writes this run's record there
|
||||
for the workflow to upload. Pruning of old objects is an S3 lifecycle rule.
|
||||
|
||||
Usage:
|
||||
uv run --no-sync python scripts/performance/compare_benchmark.py \
|
||||
benchmark_results.json \
|
||||
HISTORY_DIR \
|
||||
[--threshold 0.30] [--window 20] [--min-samples 5] [--record]
|
||||
|
||||
benchmark_results.json this run's results from benchmark_partition.py
|
||||
HISTORY_DIR dir of per-run history records (may be empty/missing)
|
||||
--threshold regression allowance over the median (default 0.30)
|
||||
--window number of most-recent records to median over (default 20)
|
||||
--min-samples below this many records, warm-up (record + pass, no gate)
|
||||
--record write this run's record into HISTORY_DIR (main runs only);
|
||||
recording happens regardless of pass/fail so the baseline
|
||||
can't get stuck.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import statistics
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _github_output(key: str, value: str) -> None:
|
||||
"""Write a key=value pair to $GITHUB_OUTPUT when running in Actions."""
|
||||
gho = os.environ.get("GITHUB_OUTPUT")
|
||||
if gho:
|
||||
with open(gho, "a") as fh:
|
||||
fh.write(f"{key}={value}\n")
|
||||
|
||||
|
||||
def _fmt(seconds: float) -> str:
|
||||
if seconds is None or math.isnan(seconds):
|
||||
return " n/a"
|
||||
return f"{seconds:7.2f}s"
|
||||
|
||||
|
||||
def _pct_diff(current: float, baseline: float) -> str:
|
||||
if not baseline:
|
||||
return " n/a"
|
||||
diff = (current - baseline) / baseline * 100
|
||||
sign = "+" if diff >= 0 else ""
|
||||
return f"{sign}{diff:.1f}%"
|
||||
|
||||
|
||||
def _runner_info() -> dict:
|
||||
"""Best-effort CPU/nproc capture, purely for later visibility of variance."""
|
||||
cpu_model = None
|
||||
try:
|
||||
for line in Path("/proc/cpuinfo").read_text().splitlines():
|
||||
if line.lower().startswith("model name"):
|
||||
cpu_model = line.split(":", 1)[1].strip()
|
||||
break
|
||||
except OSError:
|
||||
pass
|
||||
return {"cpu_model": cpu_model, "nproc": os.cpu_count()}
|
||||
|
||||
|
||||
def _is_number(value: object) -> bool:
|
||||
"""True for real numeric values; bool is rejected (it's a subclass of int)."""
|
||||
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
||||
|
||||
|
||||
def load_history(history_dir: Path) -> list[dict]:
|
||||
"""Load every per-run record from HISTORY_DIR, sorted oldest -> newest.
|
||||
|
||||
Records are deduped by sha (keeping the newest per sha by timestamp) so the
|
||||
median is correct even if S3 still holds legacy timestamped objects that share
|
||||
a sha. Records without a sha are kept individually -- they're never collapsed
|
||||
together. Records whose ``total`` is missing or non-numeric are skipped so a
|
||||
malformed object can't crash ``statistics.median``.
|
||||
"""
|
||||
if not history_dir.is_dir():
|
||||
return []
|
||||
records: list[dict] = []
|
||||
for f in history_dir.glob("*.json"):
|
||||
try:
|
||||
rec = json.loads(f.read_text())
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.warning(f"skipping unreadable history record {f.name}: {e}")
|
||||
continue
|
||||
if isinstance(rec, dict) and _is_number(rec.get("total")):
|
||||
records.append(rec)
|
||||
else:
|
||||
logger.warning(f"skipping history record {f.name}: missing/non-numeric 'total'")
|
||||
records.sort(key=lambda r: r.get("timestamp") or "")
|
||||
|
||||
# Dedupe by sha, keeping the newest record per sha. Records sort oldest->newest
|
||||
# above, so a later same-sha record overwrites the earlier one. Empty/absent sha
|
||||
# is kept per-record (do not collapse all sha-less records together).
|
||||
deduped: list[dict] = []
|
||||
by_sha: dict[str, int] = {}
|
||||
for rec in records:
|
||||
sha = rec.get("sha") or ""
|
||||
if sha and sha in by_sha:
|
||||
deduped[by_sha[sha]] = rec
|
||||
else:
|
||||
if sha:
|
||||
by_sha[sha] = len(deduped)
|
||||
deduped.append(rec)
|
||||
# Overwriting a same-sha record in place keeps the *first* occurrence's slot but
|
||||
# the *newer* record's timestamp, which can leave deduped out of chronological
|
||||
# order. Re-sort so callers can rely on history[-window:] being the newest runs.
|
||||
return sorted(deduped, key=lambda r: r.get("timestamp") or "")
|
||||
|
||||
|
||||
def build_record(current: dict) -> dict:
|
||||
"""Build this run's history record from results + CI environment metadata."""
|
||||
now = dt.datetime.now(dt.timezone.utc)
|
||||
sha = os.environ.get("GITHUB_SHA", "")
|
||||
return {
|
||||
"sha": sha,
|
||||
"run_id": int(os.environ["GITHUB_RUN_ID"]) if os.environ.get("GITHUB_RUN_ID") else None,
|
||||
"timestamp": now.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"event": os.environ.get("GITHUB_EVENT_NAME"),
|
||||
"ref": os.environ.get("GITHUB_REF_NAME"),
|
||||
"seed": False,
|
||||
"iterations": int(os.environ.get("NUM_ITERATIONS", "0")) or None,
|
||||
"runner": _runner_info(),
|
||||
"total": round(current["__total__"], 2),
|
||||
"per_file": {k: round(v, 4) for k, v in current.items() if k != "__total__"},
|
||||
}
|
||||
|
||||
|
||||
def write_record(history_dir: Path, record: dict) -> Path:
|
||||
"""Write the record into HISTORY_DIR, replacing any prior record for this sha.
|
||||
|
||||
The object name is keyed by sha alone (not timestamped), so a re-run of the
|
||||
same commit overwrites the same object. This is what keeps the rolling median
|
||||
from double-counting a commit: the workflow's ``aws s3 sync`` (no ``--delete``)
|
||||
would otherwise leave a stale, differently-named same-sha object behind. The
|
||||
timestamp survives as a record field, which is what load/sort uses.
|
||||
"""
|
||||
history_dir.mkdir(parents=True, exist_ok=True)
|
||||
sha = record.get("sha") or ""
|
||||
if sha:
|
||||
name = f"{sha[:12]}.json"
|
||||
else:
|
||||
# No sha (e.g. local/manual): fall back to a timestamp-derived name. Keep it
|
||||
# filesystem-safe by stripping the separators, as the old naming did.
|
||||
name = record["timestamp"].replace("-", "").replace(":", "") + ".json"
|
||||
out = history_dir / name
|
||||
out.write_text(json.dumps(record, indent=2) + "\n")
|
||||
return out
|
||||
|
||||
|
||||
def _median_per_file(window: list[dict]) -> dict[str, float]:
|
||||
files: set[str] = set()
|
||||
for r in window:
|
||||
files.update(r.get("per_file", {}).keys())
|
||||
medians: dict[str, float] = {}
|
||||
for fname in files:
|
||||
vals = [
|
||||
r["per_file"][fname] for r in window if _is_number(r.get("per_file", {}).get(fname))
|
||||
]
|
||||
if vals:
|
||||
medians[fname] = statistics.median(vals)
|
||||
return medians
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(
|
||||
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
||||
)
|
||||
ap.add_argument("current_results", type=Path)
|
||||
ap.add_argument("history_dir", type=Path)
|
||||
ap.add_argument("--threshold", type=float, default=0.30)
|
||||
ap.add_argument("--window", type=int, default=20)
|
||||
ap.add_argument("--min-samples", type=int, default=5)
|
||||
ap.add_argument(
|
||||
"--record", action="store_true", help="write this run into the history (main runs)"
|
||||
)
|
||||
args = ap.parse_args()
|
||||
|
||||
# --window is used as history[-args.window:]; 0 silently means "all history"
|
||||
# ([-0:] == [0:]) and negatives slice from the wrong end. Require >= 1.
|
||||
if args.window < 1:
|
||||
ap.error("--window must be >= 1")
|
||||
if args.min_samples < 0:
|
||||
ap.error("--min-samples must be >= 0")
|
||||
|
||||
current: dict[str, float] = json.loads(args.current_results.read_text())
|
||||
current_total: float = current["__total__"]
|
||||
|
||||
history = load_history(args.history_dir)
|
||||
window = history[-args.window :]
|
||||
|
||||
# Record first (main runs only) so the baseline always reflects reality,
|
||||
# regardless of whether this run passes the gate -- the opposite of the old
|
||||
# min-ratchet, which could only ever lower the bar and so got stuck.
|
||||
if args.record:
|
||||
rec_path = write_record(args.history_dir, build_record(current))
|
||||
logger.info(f"recorded this run -> {rec_path.name}")
|
||||
|
||||
# Warm-up: not enough history to gate yet. Observe and pass. An empty window can
|
||||
# never produce a baseline (statistics.median([]) raises), so it is always warm-up
|
||||
# regardless of --min-samples (which may be 0).
|
||||
if not window or len(window) < args.min_samples:
|
||||
logger.info(
|
||||
f"WARM-UP: {len(window)} of {args.min_samples} baseline samples present "
|
||||
f"-- recording only, not gating. (current total {current_total:.2f}s)"
|
||||
)
|
||||
_github_output("regression", "false")
|
||||
_github_output("warmup", "true")
|
||||
sys.exit(0)
|
||||
|
||||
baseline = statistics.median(r["total"] for r in window)
|
||||
limit = baseline * (1.0 + args.threshold)
|
||||
per_file_baseline = _median_per_file(window)
|
||||
|
||||
all_files = sorted((set(current) | set(per_file_baseline)) - {"__total__"})
|
||||
col_w = max((len(f) for f in all_files), default=40) + 2
|
||||
header = f"{'File':<{col_w}} {'Current':>9} {'Median':>9} {'Delta':>8}"
|
||||
logger.info("=" * len(header))
|
||||
logger.info(f"Partition benchmark vs rolling median of last {len(window)} main runs")
|
||||
logger.info("=" * len(header))
|
||||
logger.info(header)
|
||||
logger.info("-" * len(header))
|
||||
for fname in all_files:
|
||||
c = current.get(fname, float("nan"))
|
||||
b = per_file_baseline.get(fname, float("nan"))
|
||||
logger.info(f"{fname:<{col_w}} {_fmt(c)} {_fmt(b)} {_pct_diff(c, b):>8}")
|
||||
logger.info("-" * len(header))
|
||||
logger.info(
|
||||
f"{'TOTAL':<{col_w}} {_fmt(current_total)} {_fmt(baseline)}"
|
||||
f" {_pct_diff(current_total, baseline):>8}"
|
||||
)
|
||||
logger.info("")
|
||||
logger.info(
|
||||
f"Baseline : median of {len(window)} runs = {baseline:.2f}s "
|
||||
f"(threshold {args.threshold * 100:.0f}%, fail if > {limit:.2f}s)"
|
||||
)
|
||||
logger.info("")
|
||||
|
||||
if current_total > limit:
|
||||
excess_pct = (current_total - baseline) / baseline * 100
|
||||
logger.error(
|
||||
f"FAIL: current runtime {current_total:.2f}s exceeds the median baseline "
|
||||
f"{baseline:.2f}s by {excess_pct:.1f}% (threshold {args.threshold * 100:.0f}%, "
|
||||
f"limit {limit:.2f}s)."
|
||||
)
|
||||
_github_output("regression", "true")
|
||||
sys.exit(1)
|
||||
|
||||
logger.info(
|
||||
f"PASS: {current_total:.2f}s is within {args.threshold * 100:.0f}% of the "
|
||||
f"median baseline {baseline:.2f}s."
|
||||
)
|
||||
_github_output("regression", "false")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+1
@@ -0,0 +1 @@
|
||||
../../../example-docs/DA-1p.pdf
|
||||
+1
@@ -0,0 +1 @@
|
||||
../../../example-docs/DA-619p.pdf
|
||||
@@ -0,0 +1 @@
|
||||
../../../example-docs/book-war-and-peace-1225p.txt
|
||||
@@ -0,0 +1 @@
|
||||
../../../example-docs/book-war-and-peace-1p.txt
|
||||
@@ -0,0 +1 @@
|
||||
../../../example-docs/example-10k-1p.html
|
||||
@@ -0,0 +1 @@
|
||||
../../../example-docs/example-10k-230p.html
|
||||
@@ -0,0 +1 @@
|
||||
../../../example-docs/handbook-1p.docx
|
||||
@@ -0,0 +1 @@
|
||||
../../../example-docs/handbook-872p.docx
|
||||
@@ -0,0 +1 @@
|
||||
../../../example-docs/layout-parser-paper-10p.jpg
|
||||
@@ -0,0 +1 @@
|
||||
../../../example-docs/layout-parser-paper-fast.jpg
|
||||
@@ -0,0 +1 @@
|
||||
../../../example-docs/layout-parser-paper.pdf
|
||||
@@ -0,0 +1 @@
|
||||
../../../example-docs/layout-parser-paper.pdf
|
||||
@@ -0,0 +1 @@
|
||||
../../../example-docs/science-exploration-1p.pptx
|
||||
@@ -0,0 +1 @@
|
||||
../../../example-docs/science-exploration-369p.pptx
|
||||
Executable
+21
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# get a string representing the system stats. we should be able to infer
|
||||
# this from aws types, but this guarantees we have the info we need in all cases
|
||||
|
||||
# hack to get gpus available for processing
|
||||
# assumes nvidia drivers available for inference tasks
|
||||
if command -v nvidia-smi &>/dev/null; then
|
||||
gpu=$(nvidia-smi --query-gpu=name --format=csv,noheader | wc -l)
|
||||
else
|
||||
gpu="0"
|
||||
fi
|
||||
if command -v sysctl >/dev/null && command -v system_profiler >/dev/null; then
|
||||
cpu=$(sysctl -n hw.logicalcpu_max)
|
||||
mem=$(sysctl -n hw.memsize | awk '{printf "%.0fGB",$0/1024/1024/1024}')
|
||||
else
|
||||
cpu=$(getconf _NPROCESSORS_ONLN)
|
||||
mem=$(grep 'MemTotal' /proc/meminfo | awk '{printf "%.0fGB",$2/1024/1024}')
|
||||
fi
|
||||
|
||||
echo "${cpu}cpu_${gpu}gpu_${mem}mem"
|
||||
Executable
+365
@@ -0,0 +1,365 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Performance profiling and visualization of code using cProfile and memray.
|
||||
|
||||
# Environment Variables:
|
||||
# - DOCKER_TEST: Set to true to run profiling inside a Docker container (default: false)
|
||||
|
||||
# Usage:
|
||||
# - Run the script and choose the profiling mode: 'run' or 'view'.
|
||||
# - In the 'run' mode, you can profile custom files or select existing test files.
|
||||
# - In the 'view' mode, you can view previously generated profiling results.
|
||||
# - The script supports time profiling with cProfile and memory profiling with memray.
|
||||
# - Users can choose different visualization options such as flamegraphs, tables, trees, summaries, and statistics.
|
||||
# - Test documents are (optionally) synced from an S3 bucket to a local directory before running the profiles.
|
||||
|
||||
# Dependencies:
|
||||
# - memray package for memory profiling and visualization.
|
||||
# - flameprof and snakeviz for time profiling and visualization.
|
||||
# - AWS CLI for syncing files from S3 (if applicable).
|
||||
|
||||
# Package dependencies can be installed with `uv pip install -r scripts/performance/requirements.txt`
|
||||
|
||||
# Usage example:
|
||||
# ./scripts/performance/profile.sh
|
||||
|
||||
# NOTE: because memray does not build wheels for ARM-Linux, this script can not run in an ARM Docker container on an M1 Mac (though emulated AMD would work).
|
||||
|
||||
# Validate dependencies
|
||||
check_python_module() {
|
||||
if ! python3 -c "import $1" >/dev/null 2>&1; then
|
||||
echo "Error: Python module $1 is not installed. Please install required dependencies with 'uv pip install -r scripts/performance/requirements.txt'."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
validate_dependencies() {
|
||||
check_python_module memray
|
||||
check_python_module flameprof
|
||||
}
|
||||
|
||||
# only validate in non-docker context (since we install dependencies on the fly in docker)
|
||||
if [[ "$DOCKER_TEST" != "true" ]]; then
|
||||
validate_dependencies
|
||||
fi
|
||||
|
||||
SCRIPT_DIR=$(dirname "$0")
|
||||
# Convert the relative path to module notation
|
||||
MODULE_PATH=${SCRIPT_DIR////.}
|
||||
# Remove the leading dot if it exists
|
||||
MODULE_PATH=${MODULE_PATH#.}
|
||||
# Remove the leading dot if it exists again
|
||||
MODULE_PATH=${MODULE_PATH#\.}
|
||||
|
||||
PROFILE_RESULTS_DIR="$SCRIPT_DIR/profile_results"
|
||||
|
||||
# Create PROFILE_RESULTS_DIR if it doesn't exist
|
||||
mkdir -p "$PROFILE_RESULTS_DIR"
|
||||
|
||||
if [[ "$DOCKER_TEST" == "true" ]]; then
|
||||
SCRIPT_PARENT_DIR=$(dirname "$(dirname "$(realpath "$0")")")
|
||||
docker run -it --rm -v "$SCRIPT_PARENT_DIR:/home/unstructured/scripts" unstructured:dev /bin/bash -c "
|
||||
cd unstructured/
|
||||
uv pip install -r scripts/performance/requirements.txt
|
||||
echo \"Warming the Docker container by running a small partitioning job..\"
|
||||
python3 -c 'from unstructured.partition.auto import partition; partition(\"'""$SCRIPT_DIR/warmup_docs/warmup.pdf'\", strategy=\"hi_res\")[1]'
|
||||
./scripts/performance/profile.sh
|
||||
"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
check_display() {
|
||||
if system_profiler SPDisplaysDataType 2>/dev/null | grep -q "Display Type"; then
|
||||
return 0 # Display is present
|
||||
else
|
||||
return 1 # Display is not present (headless context)
|
||||
fi
|
||||
}
|
||||
|
||||
view_profile_headless() {
|
||||
# Several of the visualization options require a graphical interface. If DISPLAY is not set, we can't use those options.
|
||||
|
||||
extension=".bin"
|
||||
result_file="${result_file%.*}$extension"
|
||||
|
||||
if [[ ! -f "$result_file" ]]; then
|
||||
unset result_file # Unset the result_file variable to go back to the "Select a file" view
|
||||
echo "Result file not found. Please choose a different profile type or go back."
|
||||
else
|
||||
while true; do
|
||||
read -r -p "Choose visualization type: (1) tree (2) summary (3) stats (b) back, (q) quit: " -n 1 visualization_type
|
||||
echo
|
||||
|
||||
if [[ $visualization_type == "b" ]]; then
|
||||
unset result_file # Unset the result_file variable to go back to the "Select a file" view
|
||||
break
|
||||
elif [[ $visualization_type == "q" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
case $visualization_type in
|
||||
"1")
|
||||
python3 -m memray tree "$result_file"
|
||||
;;
|
||||
"2")
|
||||
python3 -m memray summary "$result_file"
|
||||
;;
|
||||
"3")
|
||||
python3 -m memray stats "$result_file"
|
||||
;;
|
||||
*)
|
||||
echo "Invalid visualization type. Please try again."
|
||||
;;
|
||||
esac
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
view_profile_with_head() {
|
||||
while true; do
|
||||
read -r -p "Choose profile type: (1) time (2) memory (3) speedscope (b) back, (q) quit: " -n 1 profile_type
|
||||
echo
|
||||
|
||||
if [[ $profile_type == "b" ]]; then
|
||||
unset result_file # Unset the result_file variable to go back to the "Select a file" view
|
||||
break
|
||||
elif [[ $profile_type == "q" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ $profile_type == "1" ]]; then
|
||||
extension=".prof"
|
||||
elif [[ $profile_type == "2" ]]; then
|
||||
extension=".bin"
|
||||
elif [[ $profile_type == "3" ]]; then
|
||||
extension=".speedscope"
|
||||
else
|
||||
echo "Invalid profile type. Please try again."
|
||||
continue
|
||||
fi
|
||||
|
||||
result_file="${result_file%.*}$extension"
|
||||
|
||||
if [[ ! -f "$result_file" ]]; then
|
||||
echo "Result file not found. Please choose a different profile type or go back."
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ $profile_type == "3" ]]; then
|
||||
speedscope "$result_file"
|
||||
elif [[ $profile_type == "2" ]]; then
|
||||
while true; do
|
||||
read -r -p "Choose visualization type: (1) flamegraph (2) table (3) tree (4) summary (5) stats (b) back, (q) quit: " -n 1 visualization_type
|
||||
echo
|
||||
|
||||
if [[ $visualization_type == "b" ]]; then
|
||||
break
|
||||
elif [[ $visualization_type == "q" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
case $visualization_type in
|
||||
"1")
|
||||
rm -f "${result_file}.memray.html"
|
||||
python3 -m memray flamegraph -o "${result_file}.memray.html" "$result_file"
|
||||
open "${result_file}.memray.html"
|
||||
;;
|
||||
"2")
|
||||
rm -f "${result_file}.table.html"
|
||||
python3 -m memray table -o "${result_file}.table.html" "$result_file"
|
||||
open "${result_file}.table.html"
|
||||
;;
|
||||
"3")
|
||||
python3 -m memray tree "$result_file"
|
||||
;;
|
||||
"4")
|
||||
python3 -m memray summary "$result_file"
|
||||
;;
|
||||
"5")
|
||||
python3 -m memray stats "$result_file"
|
||||
;;
|
||||
*)
|
||||
echo "Invalid visualization type. Please try again."
|
||||
;;
|
||||
esac
|
||||
done
|
||||
else
|
||||
while true; do
|
||||
read -r -p "Choose visualization type: (1) flamegraph (2) snakeviz (b) back, (q) quit: " -n 1 visualization_type
|
||||
echo
|
||||
|
||||
if [[ $visualization_type == "b" ]]; then
|
||||
break
|
||||
elif [[ $visualization_type == "q" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
case $visualization_type in
|
||||
"1")
|
||||
flameprof_file="${result_file}.flameprof.svg"
|
||||
rm -f "$flameprof_file"
|
||||
python3 -m flameprof "$result_file" >"$flameprof_file"
|
||||
open "$flameprof_file"
|
||||
;;
|
||||
"2")
|
||||
snakeviz "$result_file"
|
||||
;;
|
||||
*)
|
||||
echo "Invalid visualization type. Please try again."
|
||||
;;
|
||||
esac
|
||||
done
|
||||
fi
|
||||
|
||||
break # Return to the beginning
|
||||
done
|
||||
}
|
||||
|
||||
view_profile() {
|
||||
|
||||
if [ -n "$1" ]; then
|
||||
result_file="$1"
|
||||
fi
|
||||
while true; do
|
||||
if [[ -z $result_file ]]; then
|
||||
echo "Available result files:"
|
||||
result_files=("$PROFILE_RESULTS_DIR"/*.bin)
|
||||
if [[ ${#result_files[@]} -eq 0 ]]; then
|
||||
echo "No result files found."
|
||||
return
|
||||
fi
|
||||
|
||||
for ((i = 0; i < ${#result_files[@]}; i++)); do
|
||||
filename="${result_files[$i]##*/}"
|
||||
filename="${filename%.*}"
|
||||
echo "$i. $filename"
|
||||
done
|
||||
|
||||
read -r -p "Enter the number corresponding to the result file you want to view (b to go back, q to quit): " selection
|
||||
if [[ $selection == "b" ]]; then
|
||||
return
|
||||
elif [[ $selection == "q" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
result_file="${result_files[$selection]}"
|
||||
fi
|
||||
|
||||
if check_display; then
|
||||
view_profile_with_head "$result_file"
|
||||
else
|
||||
view_profile_headless "$result_file"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
run_profile() {
|
||||
while true; do
|
||||
read -r -p "Choose an option: 1) Existing test file, (2) Custom file, (b) back, (q) quit: " -n 1 option
|
||||
echo
|
||||
|
||||
if [[ $option == "b" ]]; then
|
||||
return
|
||||
elif [[ $option == "q" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ $option == "1" ]]; then
|
||||
echo "Available test files:"
|
||||
test_files=("$SCRIPT_DIR/docs"/*)
|
||||
if [[ ${#test_files[@]} -eq 0 ]]; then
|
||||
echo "No test files found."
|
||||
return
|
||||
fi
|
||||
|
||||
for ((i = 0; i < ${#test_files[@]}; i++)); do
|
||||
echo "$i. ${test_files[$i]}"
|
||||
done
|
||||
|
||||
read -r -p "Enter the number corresponding to the test file you want to run followed by return (b to go back, q to quit): " selection
|
||||
if [[ $selection == "b" ]]; then
|
||||
return
|
||||
elif [[ $selection == "q" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
test_file="${test_files[$selection]}"
|
||||
elif [[ $option == "2" ]]; then
|
||||
read -r -p "Enter the path to the custom file: " test_file
|
||||
else
|
||||
echo "Invalid option. Please try again."
|
||||
continue
|
||||
fi
|
||||
|
||||
# Delete the output files if they exist
|
||||
rm -f "$PROFILE_RESULTS_DIR/${test_file##*/}.prof"
|
||||
rm -f "$PROFILE_RESULTS_DIR/${test_file##*/}.bin"
|
||||
|
||||
# Pick the strategy
|
||||
while true; do
|
||||
read -r -p "Choose a strategy: 1) auto, (2) fast, (3) hi_res, (4) ocr_only (b) back, (q) quit: " -n 1 strategy_option
|
||||
echo
|
||||
|
||||
if [[ $strategy_option == "b" ]]; then
|
||||
return
|
||||
elif [[ $strategy_option == "q" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
case $strategy_option in
|
||||
"1")
|
||||
strategy="auto"
|
||||
break
|
||||
;;
|
||||
"2")
|
||||
strategy="fast"
|
||||
break
|
||||
;;
|
||||
"3")
|
||||
strategy="hi_res"
|
||||
break
|
||||
;;
|
||||
"4")
|
||||
strategy="ocr_only"
|
||||
break
|
||||
;;
|
||||
*)
|
||||
echo "Invalid strategy option. Please try again."
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "Running time profile..."
|
||||
python3 -m cProfile -s cumulative -o "$PROFILE_RESULTS_DIR/${test_file##*/}.prof" -m "$MODULE_PATH.run_partition" "$test_file" "$strategy"
|
||||
echo "Running memory profile..."
|
||||
python3 -m memray run -o "$PROFILE_RESULTS_DIR/${test_file##*/}.bin" -m "$MODULE_PATH.run_partition" "$test_file" "$strategy"
|
||||
echo "Running py-spy for detailed run time profiling (this can take some time)..."
|
||||
py-spy record --subprocesses -i -o "$PROFILE_RESULTS_DIR/${test_file##*/}.speedscope" --format speedscope -- python3 -m "$MODULE_PATH.run_partition" "$test_file" "$strategy"
|
||||
echo "Profiling completed."
|
||||
echo "Viewing results for $test_file"
|
||||
echo "The py-spy produced speedscope profile can be viewed on https://www.speedscope.app or locally by installing via 'npm install -g speedscope'"
|
||||
result_file=$PROFILE_RESULTS_DIR/$(basename "$test_file")
|
||||
view_profile "${result_file}.bin" # Go directly to view mode
|
||||
done
|
||||
}
|
||||
|
||||
while true; do
|
||||
if [[ -n "$1" ]]; then
|
||||
mode="$1"
|
||||
fi
|
||||
|
||||
if [[ -z $result_file ]]; then
|
||||
read -r -p "Choose mode: (1) run, (2) view, (q) quit: " -n 1 mode
|
||||
echo
|
||||
fi
|
||||
|
||||
if [[ $mode == "1" ]]; then
|
||||
run_profile
|
||||
elif [[ $mode == "2" ]]; then
|
||||
unset result_file # Unset the result_file variable before entering the "View" mode
|
||||
view_profile
|
||||
elif [[ $mode == "q" ]]; then
|
||||
exit 0
|
||||
else
|
||||
echo "Invalid mode. Please choose 'view', 'run', or 'quit'."
|
||||
fi
|
||||
done
|
||||
@@ -0,0 +1,266 @@
|
||||
"""Quick local benchmark for PDF partition cold/warm timing.
|
||||
|
||||
Examples:
|
||||
uv run --active --frozen --no-sync scripts/performance/quick_partition_bench.py \
|
||||
--pdf example-docs/pdf/DA-1p.pdf --strategy fast --repeats 4 --warmups 1 --mode both
|
||||
|
||||
uv run --active --frozen --no-sync scripts/performance/quick_partition_bench.py \
|
||||
--pdf example-docs/pdf/DA-1p.pdf --pdf example-docs/pdf/chevron-page.pdf \
|
||||
--strategy hi_res --repeats 3 --warmups 1 --mode both
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import json
|
||||
import statistics
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
|
||||
def _partition_once(pdf: str, strategy: str) -> dict[str, object]:
|
||||
sink_out = io.StringIO()
|
||||
sink_err = io.StringIO()
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
from unstructured.partition.auto import partition
|
||||
|
||||
with redirect_stdout(sink_out), redirect_stderr(sink_err):
|
||||
elements = partition(filename=pdf, strategy=strategy)
|
||||
return {
|
||||
"ok": True,
|
||||
"elapsed_s": time.perf_counter() - start,
|
||||
"elements": len(elements),
|
||||
}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
|
||||
|
||||
|
||||
def _summary(values: list[float]) -> dict[str, float]:
|
||||
return {
|
||||
"mean_s": statistics.mean(values),
|
||||
"median_s": statistics.median(values),
|
||||
"min_s": min(values),
|
||||
"max_s": max(values),
|
||||
"stdev_s": statistics.stdev(values) if len(values) > 1 else 0.0,
|
||||
}
|
||||
|
||||
|
||||
def _run_cold(pdf: str, strategy: str, repeats: int) -> tuple[list[float], int, list[str]]:
|
||||
times: list[float] = []
|
||||
elements = -1
|
||||
errors: list[str] = []
|
||||
|
||||
for _ in range(repeats):
|
||||
proc = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
__file__,
|
||||
"--_child",
|
||||
"--pdf",
|
||||
pdf,
|
||||
"--strategy",
|
||||
strategy,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
lines = [line.strip() for line in (proc.stdout or "").splitlines() if line.strip()]
|
||||
json_line = next((line for line in reversed(lines) if line.startswith("{")), "")
|
||||
if not json_line:
|
||||
stderr_tail = (proc.stderr or "").strip().splitlines()
|
||||
detail = stderr_tail[-1] if stderr_tail else "no json output"
|
||||
errors.append(f"child failed rc={proc.returncode} ({detail})")
|
||||
continue
|
||||
|
||||
row = json.loads(json_line)
|
||||
if bool(row.get("ok")):
|
||||
times.append(float(row["elapsed_s"]))
|
||||
elements = int(row["elements"])
|
||||
else:
|
||||
errors.append(str(row.get("error", "unknown error")))
|
||||
|
||||
return times, elements, errors
|
||||
|
||||
|
||||
def _run_warm(
|
||||
pdf: str,
|
||||
strategy: str,
|
||||
repeats: int,
|
||||
warmups: int,
|
||||
) -> tuple[list[float], int, list[str]]:
|
||||
errors: list[str] = []
|
||||
|
||||
for _ in range(warmups):
|
||||
row = _partition_once(pdf=pdf, strategy=strategy)
|
||||
if not bool(row.get("ok")):
|
||||
errors.append(str(row.get("error", "unknown error")))
|
||||
return [], -1, errors
|
||||
|
||||
times: list[float] = []
|
||||
elements = -1
|
||||
for _ in range(repeats):
|
||||
row = _partition_once(pdf=pdf, strategy=strategy)
|
||||
if bool(row.get("ok")):
|
||||
times.append(float(row["elapsed_s"]))
|
||||
elements = int(row["elements"])
|
||||
else:
|
||||
errors.append(str(row.get("error", "unknown error")))
|
||||
|
||||
return times, elements, errors
|
||||
|
||||
|
||||
def _collect_pdfs(pdf_args: list[str], pdf_dir_args: list[str]) -> list[str]:
|
||||
paths = [str(Path(p)) for p in pdf_args]
|
||||
|
||||
for pdf_dir in pdf_dir_args:
|
||||
root = Path(pdf_dir)
|
||||
if not root.is_dir():
|
||||
raise FileNotFoundError(f"pdf-dir does not exist: {root}")
|
||||
paths.extend(str(p) for p in sorted(root.rglob("*.pdf")))
|
||||
|
||||
if not paths:
|
||||
raise ValueError("Provide at least one --pdf or --pdf-dir")
|
||||
|
||||
deduped = [Path(p) for p in dict.fromkeys(paths)]
|
||||
missing = [str(p) for p in deduped if not p.exists()]
|
||||
if missing:
|
||||
raise FileNotFoundError(f"Missing files: {', '.join(missing)}")
|
||||
|
||||
return [str(p) for p in deduped]
|
||||
|
||||
|
||||
def _print_mode(label: str, values: list[float], elements: int, errors: list[str]) -> None:
|
||||
if not values:
|
||||
first = errors[0] if errors else "unknown error"
|
||||
print(f" {label} FAILED ({first})", flush=True)
|
||||
return
|
||||
|
||||
s = _summary(values)
|
||||
print(
|
||||
f" {label} mean={s['mean_s']:.4f}s median={s['median_s']:.4f}s "
|
||||
f"min={s['min_s']:.4f}s max={s['max_s']:.4f}s n={len(values)} elements={elements}",
|
||||
flush=True,
|
||||
)
|
||||
if errors:
|
||||
print(f" {label} partial_failures={len(errors)} first_error={errors[0]}", flush=True)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Quick local PDF partition benchmark")
|
||||
parser.add_argument("--pdf", action="append", default=[], help="PDF path (repeatable)")
|
||||
parser.add_argument("--pdf-dir", action="append", default=[], help="Directory of PDFs")
|
||||
parser.add_argument("--strategy", default="fast", choices=["fast", "hi_res", "auto"])
|
||||
parser.add_argument("--repeats", type=int, default=3)
|
||||
parser.add_argument("--warmups", type=int, default=1)
|
||||
parser.add_argument("--mode", default="both", choices=["cold", "warm", "both"])
|
||||
parser.add_argument("--json-out", default="", help="Optional JSON output path")
|
||||
parser.add_argument("--_child", action="store_true", help=argparse.SUPPRESS)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args._child:
|
||||
print(json.dumps(_partition_once(args.pdf[0], args.strategy)), flush=True)
|
||||
return
|
||||
|
||||
pdfs = _collect_pdfs(args.pdf, args.pdf_dir)
|
||||
|
||||
print(
|
||||
f"strategy={args.strategy} mode={args.mode} repeats={args.repeats} "
|
||||
f"warmups={args.warmups} pdf_count={len(pdfs)}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
by_mode_times: dict[str, list[float]] = {"cold": [], "warm": []}
|
||||
by_mode_file_means: dict[str, list[float]] = {"cold": [], "warm": []}
|
||||
by_mode_failed_files: dict[str, int] = {"cold": 0, "warm": 0}
|
||||
results: list[dict[str, object]] = []
|
||||
|
||||
for pdf in pdfs:
|
||||
row: dict[str, object] = {"pdf": pdf}
|
||||
print(f"FILE {pdf}", flush=True)
|
||||
|
||||
if args.mode in ("cold", "both"):
|
||||
times, elements, errors = _run_cold(pdf, args.strategy, args.repeats)
|
||||
_print_mode("cold", times, elements, errors)
|
||||
if times:
|
||||
by_mode_times["cold"].extend(times)
|
||||
by_mode_file_means["cold"].append(statistics.mean(times))
|
||||
row["cold"] = {"ok": True, "times_s": times, "elements": elements, "errors": errors}
|
||||
else:
|
||||
by_mode_failed_files["cold"] += 1
|
||||
row["cold"] = {"ok": False, "errors": errors}
|
||||
|
||||
if args.mode in ("warm", "both"):
|
||||
times, elements, errors = _run_warm(pdf, args.strategy, args.repeats, args.warmups)
|
||||
_print_mode("warm", times, elements, errors)
|
||||
if times:
|
||||
by_mode_times["warm"].extend(times)
|
||||
by_mode_file_means["warm"].append(statistics.mean(times))
|
||||
row["warm"] = {
|
||||
"ok": True,
|
||||
"times_s": times,
|
||||
"elements": elements,
|
||||
"errors": errors,
|
||||
"warmups": args.warmups,
|
||||
}
|
||||
else:
|
||||
by_mode_failed_files["warm"] += 1
|
||||
row["warm"] = {"ok": False, "errors": errors, "warmups": args.warmups}
|
||||
|
||||
results.append(row)
|
||||
|
||||
aggregate: dict[str, object] = {}
|
||||
print("AGGREGATE", flush=True)
|
||||
for mode in ("cold", "warm"):
|
||||
times = by_mode_times[mode]
|
||||
if not times:
|
||||
continue
|
||||
s = _summary(times)
|
||||
file_mean = statistics.mean(by_mode_file_means[mode])
|
||||
succeeded = len(by_mode_file_means[mode])
|
||||
failed = by_mode_failed_files[mode]
|
||||
aggregate[mode] = {
|
||||
"summary": s,
|
||||
"file_mean_s": file_mean,
|
||||
"samples": len(times),
|
||||
"succeeded_files": succeeded,
|
||||
"failed_files": failed,
|
||||
}
|
||||
print(
|
||||
f" {mode} succeeded_files={succeeded} failed_files={failed} "
|
||||
f"file_mean={file_mean:.4f}s mean={s['mean_s']:.4f}s median={s['median_s']:.4f}s "
|
||||
f"min={s['min_s']:.4f}s max={s['max_s']:.4f}s "
|
||||
f"stdev={s['stdev_s']:.4f}s samples={len(times)}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
if args.json_out:
|
||||
out = Path(args.json_out)
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
out.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"strategy": args.strategy,
|
||||
"mode": args.mode,
|
||||
"repeats": args.repeats,
|
||||
"warmups": args.warmups,
|
||||
"pdf_count": len(pdfs),
|
||||
"per_file": results,
|
||||
"aggregate": aggregate,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
print(f"json_out={out}", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,4 @@
|
||||
flameprof>=0.4
|
||||
memray>=1.7.0
|
||||
snakeviz>=2.2.0
|
||||
py-spy>=0.3.14
|
||||
@@ -0,0 +1,19 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
from unstructured.partition.auto import partition
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 3:
|
||||
print(
|
||||
"Please provide the path to the file as the first argument and the strategy as the "
|
||||
"second argument.",
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
file_path = sys.argv[1]
|
||||
strategy = sys.argv[2]
|
||||
model_name = sys.argv[3] if len(sys.argv) > 3 else os.environ.get("PARTITION_MODEL_NAME")
|
||||
result = partition(file_path, strategy=strategy, model_name=model_name)
|
||||
# access element in the return value to make sure we got something back, otherwise error
|
||||
result[1]
|
||||
@@ -0,0 +1,38 @@
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
from unstructured.partition.auto import partition
|
||||
|
||||
|
||||
def warm_up_process(filename):
|
||||
warmup_dir = os.path.join(os.path.dirname(__file__), "warmup-docs")
|
||||
warmup_file = os.path.join(warmup_dir, f"warmup{os.path.splitext(filename)[1]}")
|
||||
|
||||
if os.path.exists(warmup_file):
|
||||
partition(warmup_file, strategy="fast")
|
||||
else:
|
||||
partition(filename, strategy="fast")
|
||||
|
||||
|
||||
def measure_execution_time(filename, iterations, strategy):
|
||||
total_time = 0.0
|
||||
|
||||
for _ in range(iterations):
|
||||
start_time = time.time()
|
||||
partition(filename, strategy=strategy)
|
||||
end_time = time.time()
|
||||
execution_time = end_time - start_time
|
||||
total_time += execution_time
|
||||
|
||||
average_time = total_time / iterations
|
||||
print("Average time:", average_time)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
filename = sys.argv[1]
|
||||
iterations = int(sys.argv[2])
|
||||
strategy = sys.argv[3]
|
||||
|
||||
warm_up_process(filename)
|
||||
measure_execution_time(filename, iterations, strategy)
|
||||
@@ -0,0 +1 @@
|
||||
../../../example-docs/handbook-1p.docx
|
||||
@@ -0,0 +1 @@
|
||||
../../../example-docs/example-10k-1p.html
|
||||
@@ -0,0 +1 @@
|
||||
../../../example-docs/layout-parser-paper-fast.jpg
|
||||
@@ -0,0 +1 @@
|
||||
../../../example-docs/layout-parser-paper-fast.pdf
|
||||
@@ -0,0 +1 @@
|
||||
../../../example-docs/science-exploration-1p.pptx
|
||||
@@ -0,0 +1 @@
|
||||
../../../example-docs/book-war-and-peace-1p.txt
|
||||
Reference in New Issue
Block a user