Files
MinerU/tools/benchmark_basic_nnpack.py
myhloli c3d34f379f test(perf): add CPU and MPS detector benchmark tools
Freeze real detection inputs and replay isolated device, fusion, shape, microbatch, and NNPACK strategies with output parity checks and memory accounting. Add demo PDF Basic/OCR stage timing and keep explicit NNPACK-on controls independent of the production default.

Refs #5632
2026-10-13 01:45:37 +08:00

365 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""在 CPU 上用 demo1/demo2 强制 OCR 跑 Basic,简单比较全局 NNPACK 开关。"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import platform
import re
import shutil
import statistics
import subprocess
import sys
import threading
import time
from collections import Counter
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from analyze_mps_det_service import differences # noqa: E402
from benchmark_det_corpus import mac_memory_diagnostics # noqa: E402
from benchmark_det_corpus import det_source_fingerprint # noqa: E402
from benchmark_mps_det_service import write_json # noqa: E402
DOCUMENTS = (ROOT / "demo/pdfs/demo1.pdf", ROOT / "demo/pdfs/demo2.pdf")
STAGES = {
"pdf.layout": "Layout",
"pdf.formulas": "MFR(含首次加载)",
"pdf.ocr_detection": "正文 OCR det",
"pdf.ocr_recognition": "正文 OCR rec",
"pdf.table_recognition": "表格识别(含表内 OCR)",
"pdf.formula_numbers": "公式编号(含 OCR rec)",
"model.initialize": "模型初始化(嵌套,不能重复相加)",
}
def sha256(path: Path) -> str:
"""分块校验权重与样本,避免一次性读取大权重增加内存占用。"""
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(8 * 1024**2), b""):
digest.update(chunk)
return digest.hexdigest()
def inspect_models() -> list[dict[str, Any]]:
"""核对已加载 Torch 模型的全部参数确实为 CPU FP32,并记录权重位置及实例复用。"""
import torch
from mineru.model.runtime.hybrid import AtomModelSingleton
records = []
for key, wrapper in AtomModelSingleton._models.items():
components = [wrapper]
if hasattr(wrapper, "text_sys"):
components = [wrapper.text_sys.text_detector, wrapper.text_sys.text_recognizer]
elif hasattr(wrapper, "text_detector"):
components = [wrapper.text_detector, wrapper.text_recognizer]
for model in components:
network = getattr(model, "net", None)
if network is None:
network = getattr(model, "model", None)
if not isinstance(network, torch.nn.Module):
continue
parameters = list(network.parameters())
devices = sorted({str(parameter.device) for parameter in parameters})
dtypes = sorted({str(parameter.dtype) for parameter in parameters if parameter.is_floating_point()})
assert devices == ["cpu"] and dtypes == ["torch.float32"], (type(model).__name__, devices, dtypes)
weight = getattr(model, "weights_path", None) or getattr(model, "model_dir", None)
records.append(
{
"key": str(key),
"class": type(model).__name__,
"id": id(model),
"network_id": id(network),
"devices": devices,
"dtypes": dtypes,
"weight": str(weight) if weight else None,
}
)
assert any(row["class"] == "PPDocLayoutV2LayoutModel" for row in records), "未核对到 Torch Layout"
assert any(row["class"] == "FormulaRecognizer" for row in records), "未核对到 Torch MFR"
assert any(row["class"] == "TextDetector" for row in records), "未核对到 Torch det"
assert any(row["class"] == "TextRecognizer" for row in records), "未核对到 Torch rec"
return records
def worker(args: argparse.Namespace) -> None:
"""在独立进程只改变 NNPACK 开关,通过公开 SDK 完成两份 PDF 的真实 CPU Basic/OCR 解析。"""
os.environ["MINERU_DEVICE_MODE"] = "cpu"
os.environ["MINERU_TABLE_DEVICE"] = "cpu"
os.environ["MINERU_PROFILE_STAGES"] = "1"
import psutil
import torch
from loguru import logger
from mineru.config import config
from mineru.model.runtime.cpu_threads import initialize_torch_threads
from mineru.parser import parse
from mineru.parser.writer import FileBasedDataWriter
config.model.small_backend = "torch"
config.llm_aided.features.title_leveling = False
config.llm_aided.features.cross_page_table_cell_merge = False
initialize_torch_threads()
source_before = det_source_fingerprint()
torch.backends.nnpack.set_flags(args.policy == "on")
target = args.output / args.policy
target.mkdir(parents=True, exist_ok=False)
current: dict[str, Any] | None = None
process = psutil.Process()
stopped = threading.Event()
def record_stage(message: Any) -> None:
"""收集生产 stage_timer 日志,保留阶段嵌套关系对应的原始完成时间。"""
match = re.search(r"MinerU stage: name=([^,]+), elapsed_s=([0-9.]+)", message.record["message"])
if match is not None and current is not None:
current["stage_events"].append({"name": match[1], "seconds": float(match[2]), "finished_at": time.perf_counter()})
def sample_memory() -> None:
"""半秒采集本进程 RSS/footprint,采样峰不代表算子内部瞬时峰值。"""
with (target / "memory-samples.jsonl").open("w", encoding="utf-8") as stream:
while not stopped.wait(0.5):
value = {"time": time.time(), "rss": process.memory_info().rss, **mac_memory_diagnostics()}
if current is not None:
value["document"] = current["document"]
stream.write(json.dumps(value) + "\n")
stream.flush()
sink = logger.add(record_stage, level="INFO", format="{message}")
monitor = threading.Thread(target=sample_memory, daemon=True)
monitor.start()
records = []
weights: dict[str, str] = {}
try:
for path in DOCUMENTS:
current = {"document": path.stem, "stage_events": []}
started = time.perf_counter()
result = parse(path, tier="basic", ocr_mode="ocr", image_analysis=False, page_range="all")
current["parse_seconds"] = time.perf_counter() - started
current["pages"] = len(result.pages)
current["metadata"] = result.middle_json.extensions["mineru"]
assert current["pages"] == (13 if path.stem == "demo1" else 6)
assert current["metadata"] == {"tier": "basic", "parse_mode": "ocr"}
stages: Counter[str] = Counter()
for event in current["stage_events"]:
stages[event["name"]] += event["seconds"]
current["stages"] = dict(stages)
for stage in ("pdf.layout", "pdf.formulas", "pdf.ocr_detection", "pdf.ocr_recognition"):
assert stages[stage] > 0, f"{path.stem} 未实际执行 {stage}"
current["models"] = inspect_models()
current["memory_after_parse"] = {"rss": process.memory_info().rss, **mac_memory_diagnostics()}
export_started = time.perf_counter()
result.save(FileBasedDataWriter(str(target / path.stem)))
current["export_seconds_excluded"] = time.perf_counter() - export_started
for model in current["models"]:
if not model["weight"]:
continue
weight_path = Path(model["weight"])
files = list(weight_path.glob("*.safetensors")) if weight_path.is_dir() else [weight_path]
for weight_file in files:
if str(weight_file) not in weights:
weights[str(weight_file)] = sha256(weight_file)
records.append(current)
write_json(target / "results.json", records)
print(f"DONE {args.policy} {path.stem} parse={current['parse_seconds']:.3f}s stages={dict(stages)}", flush=True)
current = None
del result
finally:
stopped.set()
monitor.join()
logger.remove(sink)
write_json(
target / "environment.json",
{
"pid": os.getpid(),
"policy": args.policy,
"nnpack_enabled": torch._C._get_nnpack_enabled(),
"device": os.environ["MINERU_DEVICE_MODE"],
"table_device": os.environ["MINERU_TABLE_DEVICE"],
"small_backend": config.model.small_backend,
"torch": torch.__version__,
"torch_commit": torch.version.git_version,
"python": platform.python_version(),
"platform": platform.platform(),
"threads": torch.get_num_threads(),
"interop_threads": torch.get_num_interop_threads(),
"documents_sha256": {str(path.relative_to(ROOT)): sha256(path) for path in DOCUMENTS},
"weights_sha256": weights,
"script_sha256": sha256(Path(__file__)),
"det_source_before": source_before,
"det_source_after": det_source_fingerprint(),
"crop_fix_sha256": {
name: sha256(ROOT / name)
for name in (
"mineru/model/ocr/results.py",
"mineru/model/ocr/image.py",
"mineru/model/ocr/pytorch_paddle.py",
)
},
},
)
def summarize(args: argparse.Namespace) -> None:
"""逐项比较完整结果,按两份 PDF 合计中位数汇总各阶段,不把嵌套初始化重复相加。"""
groups: dict[str, dict[str, Any]] = {}
fingerprints = set()
for round_name in ("round-1", "round-2"):
groups[round_name] = {}
for policy in ("on", "off"):
directory = args.output / round_name / policy
rows = json.loads((directory / "results.json").read_text())
env = json.loads((directory / "environment.json").read_text())
assert env["nnpack_enabled"] == (policy == "on")
assert env["device"] == env["table_device"] == "cpu" and env["small_backend"] == "torch"
assert env["det_source_before"] == env["det_source_after"]
fingerprints.add(
json.dumps(
{key: value for key, value in env.items() if key not in {"pid", "policy", "nnpack_enabled"}}, sort_keys=True
)
)
stage_totals: Counter[str] = Counter()
for row in rows:
stage_totals.update(row["stages"])
groups[round_name][policy] = {
"rows": rows,
"parse_seconds": sum(row["parse_seconds"] for row in rows),
"stages": dict(stage_totals),
"environment": env,
}
assert len(fingerprints) == 1, "CPU 配置、线程、权重或代码不一致"
comparisons = []
comparison_root = args.output / "comparisons"
comparison_root.mkdir(exist_ok=True)
baseline = args.output / "round-1" / "on"
for round_name in groups:
for policy in groups[round_name]:
for path in DOCUMENTS:
left, right = baseline / path.stem, args.output / round_name / policy / path.stem
target = comparison_root / f"{round_name}-{policy}-{path.stem}"
target.mkdir(exist_ok=True)
result = {"round": round_name, "policy": policy, "document": path.stem}
for name in ("middle_json.json", "model_output.json", "structured_content.json"):
delta = differences(json.loads((left / name).read_text()), json.loads((right / name).read_text()))
write_json(target / (name + ".differences.json"), delta)
result[name] = {"exact": not delta, "changed_leaves": len(delta)}
result["markdown_exact"] = (left / "markdown.md").read_bytes() == (right / "markdown.md").read_bytes()
if not result["markdown_exact"]:
import difflib
(target / "markdown.diff").write_text(
"".join(
difflib.unified_diff(
(left / "markdown.md").read_text().splitlines(True),
(right / "markdown.md").read_text().splitlines(True),
)
)
)
comparisons.append(result)
medians = {
policy: {
"parse_seconds": statistics.median(groups[name][policy]["parse_seconds"] for name in groups),
"stages": {
stage: statistics.median(groups[name][policy]["stages"].get(stage, 0) for name in groups) for stage in STAGES
},
}
for policy in ("on", "off")
}
tables = [
f"| SDK parse 合计 | {medians['on']['parse_seconds']:.3f} | {medians['off']['parse_seconds']:.3f} | "
f"{1 - medians['off']['parse_seconds'] / medians['on']['parse_seconds']:.1%} |"
]
for stage, label in STAGES.items():
on, off = medians["on"]["stages"][stage], medians["off"]["stages"][stage]
tables.append(
f"| {label} | {on:.3f} | {off:.3f} | {1 - off / on:.1%} |" if on else f"| {label} | {on:.3f} | {off:.3f} | — |"
)
per_document = []
for name, group in groups.items():
for policy, value in group.items():
for row in value["rows"]:
s = row["stages"]
per_document.append(
f"| {name} | {policy} | {row['document']} | {row['parse_seconds']:.3f} | {s['pdf.layout']:.3f} | "
f"{s['pdf.formulas']:.3f} | {s['pdf.ocr_detection']:.3f} | {s['pdf.ocr_recognition']:.3f} |"
)
write_json(args.output / "summary.json", {"groups": groups, "medians": medians, "comparisons": comparisons})
env = groups["round-1"]["on"]["environment"]
report = f"""# CPU Basic/OCR:demo1+demo2,NNPACK 开关
demo1 13 页,demo2 6 页,每个独立进程按此顺序各解析一次。两轮策略顺序 on→off、off→on,共 76 页。
通过公开 `mineru.parser.parse()`:tier=basic、ocr_mode=ocr、image_analysis=False、整本。
强制 MINERU_DEVICE_MODE=cpu、MINERU_TABLE_DEVICE=cpu、small_backend=torch;核对 Layout/MFR/det/rec 全部参数为 CPU FP32。
使用 .venv4,Python {env["python"]}、PyTorch {env["torch"]},生产线程 {env["threads"]}/{env["interop_threads"]}。
没有前向预热、实验融合或 N/H/W 拆分补齐;保留生产内部已有的优化。每本文档执行真实生产清理。
两种策略都使用相同的零尺寸 OCR 裁图修复:先执行既有窄框过滤,并丢弃无法生成至少一像素裁图的退化框。
初次未修复基线在 demo2 崩溃,失败现场单独保留,不混入本轮正式统计。
首份 demo1 含模型冷加载,demo2 复用模型;不混为全热态或单模型纯 forward 速度。
使用生产 MINERU_PROFILE_STAGES 日志;MFR 阶段含首次 MFR 初始化,model.initialize 嵌套于准备/MFR,不能重复相加。
表格与公式编号还会执行 OCR,正文 det/rec 列不包括这部分;对应时间保留单独阶段。
SDK parse 包括读取、模型初始化、推理、PDF 清理和确定性后处理;结果导出、权重校验排除在计时之外。
| 阶段(两份 PDF 合计,两轮中位数) | NNPACK 开 秒 | NNPACK 关 秒 | 耗时降低 |
| --- | ---: | ---: | ---: |
{chr(10).join(tables)}
| 轮次 | NNPACK | 文档 | parse 秒 | Layout 秒 | MFR 秒 | 正文 det 秒 | 正文 rec 秒 |
| --- | --- | --- | ---: | ---: | ---: | ---: | ---: |
{chr(10).join(per_document)}
输出逐项比较完整 Middle/Model/Structured JSON 与 Markdown,未删除源元数据或对数值施加容差;完整差异见 comparisons/。
一致性结果:`{json.dumps(comparisons, ensure_ascii=False)}`。
环境、权重与输入摘要在各进程 environment.json;生产阶段事件与内存采样单独保留。
NNPACK N≥16 属于 PyTorch 启发式,并非保证本机快:此前固定 N=16 的原 batch 在关闭后转到普通卷积路径,耗时明显降低。
FFT/Winograd 变换、打包、线程调度与硬件适配可能影响实际收益;本实验不能单独裁定其中哪一项占主导。
不修改生产配置、公开接口或权重;仅适用于这两份文档的本机 CPU Torch Basic/OCR。
```bash
.venv4/bin/python tools/benchmark_basic_nnpack.py experiment --output NEW_OUTPUT
.venv4/bin/python tools/benchmark_basic_nnpack.py summarize --output {args.output}
```
"""
(args.output / "report.md").write_text(report)
print(json.dumps({"medians": medians, "comparisons": comparisons}, ensure_ascii=False), flush=True)
def experiment(args: argparse.Namespace) -> None:
"""串行执行两轮反序的独立进程对照,并保存实际使用脚本与完整结果。"""
args.output.mkdir(parents=True, exist_ok=False)
shutil.copy2(Path(__file__), args.output / "executed-script.py")
for name, policies in (("round-1", ("on", "off")), ("round-2", ("off", "on"))):
directory = args.output / name
directory.mkdir()
for policy in policies:
with (directory / f"{policy}.log").open("w") as stream:
subprocess.run(
[sys.executable, str(Path(__file__).resolve()), "worker", "--policy", policy, "--output", str(directory)],
cwd=ROOT,
stdout=stream,
stderr=subprocess.STDOUT,
check=True,
)
print(f"FINISHED {name} {policy}", flush=True)
summarize(args)
def main() -> None:
"""提供简单的完整 Basic/OCR 对照及复测入口。"""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("command", choices=("experiment", "worker", "summarize"))
parser.add_argument("--policy", choices=("on", "off"), default="on")
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
args.output = args.output.resolve()
{"experiment": experiment, "worker": worker, "summarize": summarize}[args.command](args)
if __name__ == "__main__":
main()