myhloli commited on
Commit
8d54f9c
·
1 Parent(s): 0d3f4c6

Simplify Space layout and use CPU-only ONNX runtime

Browse files
.gitattributes CHANGED
@@ -48,6 +48,4 @@ examples/epub_01.epub filter=lfs diff=lfs merge=lfs -text
48
  examples/epub_02.epub filter=lfs diff=lfs merge=lfs -text
49
  examples/pptx_01.odp filter=lfs diff=lfs merge=lfs -text
50
  examples/xlsx_01.ods filter=lfs diff=lfs merge=lfs -text
51
- docx_01.doc filter=lfs diff=lfs merge=lfs -text
52
- docx_01.docx filter=lfs diff=lfs merge=lfs -text
53
  examples/docx_01.odt filter=lfs diff=lfs merge=lfs -text
 
48
  examples/epub_02.epub filter=lfs diff=lfs merge=lfs -text
49
  examples/pptx_01.odp filter=lfs diff=lfs merge=lfs -text
50
  examples/xlsx_01.ods filter=lfs diff=lfs merge=lfs -text
 
 
51
  examples/docx_01.odt filter=lfs diff=lfs merge=lfs -text
.gitignore CHANGED
@@ -4,3 +4,5 @@ __pycache__/
4
  output/
5
  tmp/
6
  .ruff_cache/
 
 
 
4
  output/
5
  tmp/
6
  .ruff_cache/
7
+ .DS_Store
8
+ .zcode/
README.md CHANGED
@@ -23,35 +23,26 @@ Paper: https://huggingface.co/papers/2409.18839
23
 
24
  ## ZeroGPU runtime
25
 
26
- Flash TXT runs only in the CPU/PDFium sidecar. Flash OCR and every Basic PDF request
27
- use `spaces.GPU(duration=60)`. Native Office/HTML/OFD inputs remain Flash CPU.
28
- The maximum is Basic and the page limit is 20.
29
-
30
- `app.py` checks MinerU's dev HEAD once per startup, builds an isolated environment
31
- at that exact commit, verifies dependencies and integration hooks, and switches
32
- only after success. A failed update keeps the previously verified environment.
33
- No usable cached environment means startup fails clearly. The cache defaults to
34
- `~/.cache/mineru-space-runtime` and can be relocated with `MINERU_RUNTIME_CACHE`;
35
- it follows the Space's storage lifetime. The cache lives outside the Git checkout
36
- so devmode's `git clean` cannot remove the working environment. Keep `current.json`
37
- and `previous.json` for rollback. The launcher explicitly asks uv for Python 3.12:
38
- HF devmode may retain a Python 3.10 system launcher despite README metadata.
39
-
40
- Torch models load at startup through ZeroGPU's CUDA emulation. PDFium and ONNX
41
- sessions are never preloaded in that parent process. GPU-worker rendering is
42
- serial and governed by a **60-second GPU parse watchdog**, not a separate
43
- `MINERU_PDF_RENDER_TIMEOUT` or render-thread setting. The platform's `duration`
44
- may only warn when exceeded, so the watchdog terminates only the dedicated GPU
45
- worker if parsing overruns. Scheduling, worker preparation and result transfer
46
- are measured separately; cancellation does not pretend to interrupt native code.
47
-
48
- Defaults: `MINERU_INTRA_OP_NUM_THREADS=4`, `MINERU_INTER_OP_NUM_THREADS=1`,
49
- `MINERU_TABLE_DEVICE=cpu`. Set the latter to `cuda` to test table CUDA inference;
50
- sessions are created only inside the GPU worker and report their actual provider.
51
- `MINERU_PROFILE_STAGES=1` enables wall-clock stage logs (no extra CUDA synchronization).
52
- The `dispatch_to_entry_s` metric includes scheduling and worker preparation; it
53
- is not a pure GPU queue-time measurement. GPU return and parent completion
54
- timestamps expose result-transfer overhead.
55
-
56
- Cancellation stops delivering the old result and waits for the underlying GPU
57
- call before releasing its slot. It does not claim to interrupt native inference.
 
23
 
24
  ## ZeroGPU runtime
25
 
26
+ `app.py` launches the code in `mineru_space/`. Flash TXT and native Office/HTML/OFD
27
+ inputs run in a CPU/PDFium sidecar; Flash OCR and all Basic PDF requests use
28
+ ZeroGPU. The maximum is Basic, with 20 pages and a 60-second GPU parse watchdog.
29
+ Cancellation waits for native work to finish before releasing the request slot.
30
+
31
+ The Space image supplies Python 3.12. The launcher reuses that interpreter to
32
+ create an isolated environment, installs MinerU's exact dev HEAD, verifies it,
33
+ and activates it only after success. Failed updates keep an existing compatible
34
+ runtime; no usable runtime means startup fails clearly. The cache defaults to
35
+ `~/.cache/mineru-space-runtime` (`MINERU_RUNTIME_CACHE` overrides it), outside
36
+ the checkout, and follows the Space's storage lifetime. Current and previous
37
+ runtime manifests are retained for rollback.
38
+
39
+ The root `requirements.txt` installs launcher dependencies. The application
40
+ pins are in `mineru_space/runtime-requirements.txt`. Only CPU `onnxruntime` is
41
+ installed; table inference is fixed to CPU with default intra/inter-op threads
42
+ of 4/1 (`MINERU_INTRA_OP_NUM_THREADS` / `MINERU_INTER_OP_NUM_THREADS`). Torch
43
+ models still use CUDA and preload through ZeroGPU's emulation at startup.
44
+ PDFium and ONNX sessions are not initialized in that parent process.
45
+
46
+ `MINERU_PROFILE_STAGES=1` enables stage timings. Dispatch-to-entry time includes
47
+ GPU scheduling and worker preparation. GPU-worker PDF rendering is serial;
48
+ its deadline is the whole-request watchdog, not a separate render timeout.
 
 
 
 
 
 
 
 
 
VALIDATION.md DELETED
@@ -1,74 +0,0 @@
1
- # ZeroGPU 验证记录(2026-09-20)
2
-
3
- 性能对照的 MinerU 核心提交:`9168ac7b178035d77c7be2338b09fe6d281c81fb`。
4
- 在 opendatalab/MinerU devmode 的独立进程、隔离 Python 环境和测试端口执行;性能测试期间正式服务保持原版本。
5
-
6
- ## 环境与性能
7
-
8
- 实际 GPU:NVIDIA RTX PRO 6000 Blackwell Server Edition MIG 2g.48gb。
9
- 容器 CPU quota 为 16 核,进程可见 192 个逻辑核。Torch 2.13.0 CUDA 13,spaces 0.51.3。
10
- 基线为 Python 3.10.13 / CPU ORT 1.23.2;候选为 Python 3.12.14 / ORT GPU 1.29.0 / DocVortex 0.4.15。
11
- CPU 和 CUDA 候选使用同一套新环境。下表包含调度、worker 准备、解析和结果 IPC,不包含启动期预加载与输出持久化。
12
-
13
- `demo1.pdf`,13 页,Basic + Auto(实际 TXT):
14
-
15
- | 配置 | 第一次 GPU 调用 | 第二次 | 第三次 |
16
- | --- | ---: | ---: | ---: |
17
- | 原始版本 | 38.453 s | 36.108 s | 35.264 s |
18
- | 原环境,仅限制 ONNX 为 4 线程 | 23.078 s | 9.390 s | 9.559 s |
19
- | 新运行时 + 4 线程 + Torch 预加载 | 13.930 s | 7.984 s | 7.842 s |
20
- | 同一候选,表格 CUDA | 20.382 s | 8.046 s | 7.595 s |
21
-
22
- 基线三个请求使用不同 worker;其他组本次均复用了各自的 worker。不能保证未来请求一定复用。
23
- CUDA 第一次调用有 9.346 s 的调度/worker 准备时间,不能将这部分归因于模型推理。
24
- 去掉该部分后首次解析分别为 CPU 12.788 s、CUDA 10.859 s,仍存在不同阶段冷缓存的影响。
25
-
26
- 更直接的表格阶段对照:首次 CPU 1.120 s、CUDA 1.383 s;后两次 CPU 0.460/0.507 s、CUDA 0.486/0.453 s。
27
- 未观察到稳定的整份文档收益达到既定启用门槛,所以 Space 默认 `MINERU_TABLE_DEVICE=cpu`,保留 CUDA 开关。
28
- 新配置的正文回填约 3.2–3.5 s,是后续进一步分析的主要 CPU 阶段之一。
29
-
30
- ## CUDA 确认与质量
31
-
32
- 完整请求的 ONNX profiling 已确认三个表格模型真实执行 CUDA 节点:
33
-
34
- | 模型 | CUDA 节点执行数 | CPU 节点执行数 |
35
- | --- | ---: | ---: |
36
- | 表格分类 | 102 | 2 |
37
- | SLANet | 21209 | 1822 |
38
- | UNet | 198 | 30 |
39
-
40
- CPU 节点为图中保留的 CPU 运算;不是整个会话静默回退。
41
- 四组全部 12 次解析的 ModelJson pages、MiddleJson pages、布局元数据和 Markdown 与基线完全相同。
42
- 三组首次结果生成 PDF 后,13 页逐页以 900 px 栅格化,CPU 候选和 CUDA 候选均与基线像素完全一致。
43
- 人工检查了第五页旋转表格及布局标框,方向和归属保持一致。
44
- 已有基线限制:第五页竖向表格说明在重建 PDF 中仍有窄列换行,本次未改变其排版。
45
-
46
- ## 路由、界面与故障
47
-
48
- - 28 个 Space 测试在 macOS 和 Linux Python 3.12 均通过;补充启动器显式选择 Python 3.12 的测试后,macOS 共 29 个通过。覆盖完整分流矩阵、混合 PDF 选页分类、20 页约束、取消及更新事务。
49
- - 173 个相关 MinerU 测试通过,包含 ONNX 模型栈、表格自动选择及回退、Flash 调度、模型预加载、内存生命周期、公式/表格路由及异步运行时。其中自动选择变更后重跑受影响组 66 个通过,其余 107 个已在前一提交验证。
50
- - 新增代码 Ruff 通过;旧表格模块原有的 91 个类型注解告警数量和规则均未增加。
51
- - 浏览器实际完成 Flash TXT,日志 `use_gpu=false`,没有 GPU 入口事件;经过 bootstrap 启动的 Basic 默认路径也完成,界面显示约 14.9 s。
52
- - `caibao1.pdf` 前 20 页 Basic 完成,GPU 调用约 16.23 s;页号保持 0–19。Flash OCR 和 Basic OCR 扫描件分别完成。
53
- - 真实候选环境安装并校验精确 dev SHA;同 SHA 再启动跳过安装;注入网络失败和安装失败均返回同一个可用旧环境。
54
- - 平台 `duration=1` 的测试仍运行约 10.6 s 才返回,说明不能将该参数当成严格的应用超时。
55
- - 新增看门狗在真实 GPU worker 中用 0.2 s 测试预算触发退出,主进程继续存活,后续请求成功重建 worker。
56
- - 另行注入 worker 异常退出,平台返回 `GPU task aborted` 后下一次调用成功。
57
-
58
- ## 表格自动选择补充
59
-
60
- 按后续确认的范围,核心提交 `bf8a518d235eef420275e446ecfc59a598a0260a` 将表格默认设备改为 `auto`,仅覆盖表格分类、SLANet 和 UNet。安装兼容 GPU ORT 后优先选择可用 CUDA provider,失败记录原因并回退 CPU;Layout、OCR、公式的 ONNX 后端保持 CPU。
61
-
62
- 独立 ZeroGPU 请求未设置 `MINERU_TABLE_DEVICE`,三个实际模型均返回 `CUDAExecutionProvider`,并完成真实分类推理;正式 Space 仍显式设置 `cpu`。Space 重启已自动安装该精确 dev SHA,通过依赖校验并完成 Torch 预加载。Devmode 保持开启,SSH 可用。
63
-
64
- ## 复现
65
-
66
- ```bash
67
- python -m pytest -q tests
68
- python benchmark_space.py --variant preload --pdf examples/demo1.pdf --repeats 3 --output output/bench-cpu
69
- python benchmark_space.py --variant cuda --pdf examples/demo1.pdf --repeats 3 --output output/bench-cuda
70
- python benchmark_space.py --variant cuda --pdf examples/demo1.pdf --repeats 1 --profile --output output/bench-profile
71
- ```
72
-
73
- 每个性能组使用独立父进程,并串行运行。profiling 单独执行,不把其额外开销算进性能对照。
74
- 完整本地日志和样例产物保存在工作区 `output/validation/`,未提交大体积生成文件。
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app.py CHANGED
@@ -1,6 +1,6 @@
1
  """Space 启动入口;spawn 导入本模块时不会更新依赖或初始化模型。"""
2
 
3
  if __name__ == "__main__":
4
- from bootstrap import main
5
 
6
  main()
 
1
  """Space 启动入口;spawn 导入本模块时不会更新依赖或初始化模型。"""
2
 
3
  if __name__ == "__main__":
4
+ from mineru_space.bootstrap import main
5
 
6
  main()
benchmark_space.py DELETED
@@ -1,137 +0,0 @@
1
- """在独立进程中对照 ZeroGPU 冷启动、连续请求和表格 provider,不修改正式服务。"""
2
-
3
- from __future__ import annotations
4
-
5
- import argparse
6
- import json
7
- import os
8
- import time
9
- from pathlib import Path
10
-
11
-
12
- def parse_and_profile(
13
- path: str,
14
- *,
15
- tier: str,
16
- ocr_mode: str,
17
- page_range: str,
18
- request_id: str,
19
- submitted_at: float,
20
- ):
21
- """在 GPU 租约内解析并收集已创建 ONNX 会话的执行轨迹。"""
22
- import torch
23
- from space_runtime import gpu_task_entry, log_event
24
-
25
- log_event("benchmark.device", device=torch.cuda.get_device_name())
26
- result = gpu_task_entry(
27
- path,
28
- tier=tier,
29
- ocr_mode=ocr_mode,
30
- page_range=page_range,
31
- request_id=request_id,
32
- submitted_at=submitted_at,
33
- )
34
- if os.getenv("MINERU_ONNX_PROFILE_DIR"):
35
- from mineru.model.runtime.hybrid import AtomModelSingleton
36
-
37
- for model in AtomModelSingleton._models.values():
38
- # 三类表格包装器拥有不同属性,按确定的对象结构取出真实 ORT session。
39
- if hasattr(model, "sess"):
40
- session = model.sess
41
- elif hasattr(model, "table_model"):
42
- session = model.table_model.table_structure.session.session
43
- elif hasattr(model, "wired_table_model"):
44
- session = model.wired_table_model.table_structure.session.session
45
- else:
46
- continue
47
- if hasattr(session, "end_profiling"):
48
- log_event(
49
- "benchmark.onnx_profile",
50
- path=session.end_profiling(),
51
- providers=session.get_providers(),
52
- )
53
- return result
54
-
55
-
56
- def main() -> None:
57
- """每个对照组使用新父进程;PDF 持久化始终放在 sidecar 中避免污染后续 fork。"""
58
- parser = argparse.ArgumentParser()
59
- parser.add_argument(
60
- "--variant", choices=["baseline", "threads", "preload", "cuda"], required=True
61
- )
62
- parser.add_argument("--pdf", default="/app/examples/demo1.pdf")
63
- parser.add_argument("--tier", default="basic")
64
- parser.add_argument("--ocr-mode", default="auto")
65
- parser.add_argument("--page-range", default="")
66
- parser.add_argument("--repeats", type=int, default=3)
67
- parser.add_argument("--output", required=True)
68
- parser.add_argument("--profile", action="store_true")
69
- args = parser.parse_args()
70
- from space_runtime import configure_environment, preload_torch_models
71
-
72
- configure_environment()
73
- os.environ["MINERU_TABLE_DEVICE"] = "cuda" if args.variant == "cuda" else "cpu"
74
- if args.variant == "baseline":
75
- os.environ.pop("MINERU_INTRA_OP_NUM_THREADS", None)
76
- os.environ.pop("MINERU_INTER_OP_NUM_THREADS", None)
77
- output = Path(args.output).resolve()
78
- output.mkdir(parents=True, exist_ok=True)
79
- if args.profile:
80
- os.environ["MINERU_ONNX_PROFILE_DIR"] = str(output / "onnx")
81
- import spaces
82
- import pdf_sidecar
83
- from space_app import sidecar_call, shutdown_sidecar
84
-
85
- try:
86
- sidecar_call(pdf_sidecar.read_page_metadata, None)
87
- if args.variant in {"preload", "cuda"}:
88
- preload_torch_models()
89
- task = spaces.GPU(duration=60)(parse_and_profile)
90
- records = []
91
- for index in range(args.repeats):
92
- started = time.monotonic()
93
- result = task(
94
- args.pdf,
95
- tier=args.tier,
96
- ocr_mode=args.ocr_mode,
97
- page_range=args.page_range,
98
- request_id=f"{args.variant}-{index}",
99
- submitted_at=started,
100
- )
101
- elapsed = time.monotonic() - started
102
- persist_started = time.monotonic()
103
- artifacts = sidecar_call(
104
- pdf_sidecar.persist,
105
- result,
106
- args.pdf,
107
- output_root=str(output / str(index)),
108
- page_range=args.page_range,
109
- )
110
- record = {
111
- "run": index,
112
- "gpu_call_s": elapsed,
113
- "persist_s": time.monotonic() - persist_started,
114
- "artifacts": artifacts.as_state(),
115
- }
116
- records.append(record)
117
- (output / "report.json").write_text(
118
- json.dumps({"variant": args.variant, "records": records}, indent=2),
119
- encoding="utf-8",
120
- )
121
- print(
122
- json.dumps(
123
- {
124
- "benchmark": args.variant,
125
- "run": index,
126
- "gpu_call_s": elapsed,
127
- "persist_s": record["persist_s"],
128
- }
129
- ),
130
- flush=True,
131
- )
132
- finally:
133
- shutdown_sidecar()
134
-
135
-
136
- if __name__ == "__main__":
137
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
mineru_space/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """Space 运行模块;导入时不初始化模型或更新环境。"""
2
+
3
+ __all__ = []
bootstrap.py → mineru_space/bootstrap.py RENAMED
@@ -14,8 +14,6 @@ import uuid
14
  from pathlib import Path
15
 
16
  DEV_REPOSITORY = "https://github.com/myhloli/Magic-PDF.git"
17
- ORT_VERSION = "1.29.0"
18
- PYTHON_VERSION = "3.12"
19
 
20
 
21
  def run(command: list[str], *, timeout: int = 900, capture: bool = False) -> str:
@@ -53,7 +51,11 @@ def write_json(path: Path, value: dict) -> None:
53
  def validate(root: Path, environment: Path, commit: str) -> None:
54
  """在独立进程校验包来源、依赖、CPU/GPU 包冲突及所需适配接口。"""
55
  run(
56
- [str(environment / "bin/python"), str(root / "runtime_preflight.py"), commit],
 
 
 
 
57
  timeout=90,
58
  )
59
 
@@ -76,8 +78,8 @@ def active_environment(cache: Path) -> tuple[Path, dict] | None:
76
  def build_candidate(
77
  root: Path, cache: Path, commit: str, fingerprint: str
78
  ) -> tuple[Path, dict]:
79
- """依赖装入全新 venv,最后只保留 GPU 版 ORT,所有变更均不触碰旧环境。"""
80
- name = f"py{PYTHON_VERSION.replace('.', '')}-{commit[:12]}-{uuid.uuid4().hex[:8]}"
81
  environment = cache / name
82
  python = environment / "bin/python"
83
  try:
@@ -88,7 +90,7 @@ def build_candidate(
88
  "uv",
89
  "venv",
90
  "--python",
91
- PYTHON_VERSION,
92
  str(environment),
93
  ],
94
  timeout=120,
@@ -103,37 +105,10 @@ def build_candidate(
103
  "--python",
104
  str(python),
105
  "-r",
106
- str(root / "runtime-requirements.txt"),
107
  f"mineru[torch] @ git+{DEV_REPOSITORY}@{commit}",
108
  ]
109
  )
110
- # MinerU 的基础依赖会安装 CPU 包;先统一移除,随后重装 GPU 包避免共享文件残留。
111
- run(
112
- [
113
- sys.executable,
114
- "-m",
115
- "uv",
116
- "pip",
117
- "uninstall",
118
- "--python",
119
- str(python),
120
- "onnxruntime",
121
- "onnxruntime-gpu",
122
- ]
123
- )
124
- run(
125
- [
126
- sys.executable,
127
- "-m",
128
- "uv",
129
- "pip",
130
- "install",
131
- "--python",
132
- str(python),
133
- "--no-deps",
134
- f"onnxruntime-gpu=={ORT_VERSION}",
135
- ]
136
- )
137
  validate(root, environment, commit)
138
  state = {"environment": name, "commit": commit, "fingerprint": fingerprint}
139
  write_json(environment / "ready.json", state)
@@ -165,9 +140,9 @@ def select_environment(root: Path, cache: Path) -> Path:
165
  fcntl.flock(lock, fcntl.LOCK_EX)
166
  previous = active_environment(cache)
167
  fingerprint = hashlib.sha256(
168
- (root / "runtime-requirements.txt").read_bytes()
169
- + (root / "runtime_preflight.py").read_bytes()
170
- + f"{PYTHON_VERSION}:{ORT_VERSION}".encode()
171
  ).hexdigest()
172
  try:
173
  commit = dev_head()
@@ -203,7 +178,8 @@ def select_environment(root: Path, cache: Path) -> Path:
203
 
204
  def main() -> None:
205
  """只由 app.py 主入口更新环境,再以 exec 替换为真实应用进程。"""
206
- root = Path(__file__).resolve().parent
 
207
  cache = Path(
208
  os.getenv(
209
  "MINERU_RUNTIME_CACHE", str(Path.home() / ".cache/mineru-space-runtime")
@@ -214,5 +190,5 @@ def main() -> None:
214
  os.environ["PATH"] = f"{environment / 'bin'}:{os.environ.get('PATH', '')}"
215
  os.execv(
216
  str(environment / "bin/python"),
217
- [str(environment / "bin/python"), str(root / "space_app.py")],
218
  )
 
14
  from pathlib import Path
15
 
16
  DEV_REPOSITORY = "https://github.com/myhloli/Magic-PDF.git"
 
 
17
 
18
 
19
  def run(command: list[str], *, timeout: int = 900, capture: bool = False) -> str:
 
51
  def validate(root: Path, environment: Path, commit: str) -> None:
52
  """在独立进程校验包来源、依赖、CPU/GPU 包冲突及所需适配接口。"""
53
  run(
54
+ [
55
+ str(environment / "bin/python"),
56
+ str(root / "mineru_space" / "runtime_preflight.py"),
57
+ commit,
58
+ ],
59
  timeout=90,
60
  )
61
 
 
78
  def build_candidate(
79
  root: Path, cache: Path, commit: str, fingerprint: str
80
  ) -> tuple[Path, dict]:
81
+ """沿用宿主解释器,在新 venv 中一次安装依赖,不触碰旧环境。"""
82
+ name = f"py{sys.version_info.major}{sys.version_info.minor}-{commit[:12]}-{uuid.uuid4().hex[:8]}"
83
  environment = cache / name
84
  python = environment / "bin/python"
85
  try:
 
90
  "uv",
91
  "venv",
92
  "--python",
93
+ sys.executable,
94
  str(environment),
95
  ],
96
  timeout=120,
 
105
  "--python",
106
  str(python),
107
  "-r",
108
+ str(root / "mineru_space" / "runtime-requirements.txt"),
109
  f"mineru[torch] @ git+{DEV_REPOSITORY}@{commit}",
110
  ]
111
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  validate(root, environment, commit)
113
  state = {"environment": name, "commit": commit, "fingerprint": fingerprint}
114
  write_json(environment / "ready.json", state)
 
140
  fcntl.flock(lock, fcntl.LOCK_EX)
141
  previous = active_environment(cache)
142
  fingerprint = hashlib.sha256(
143
+ (root / "mineru_space" / "runtime-requirements.txt").read_bytes()
144
+ + (root / "mineru_space" / "runtime_preflight.py").read_bytes()
145
+ + sys.version.encode()
146
  ).hexdigest()
147
  try:
148
  commit = dev_head()
 
178
 
179
  def main() -> None:
180
  """只由 app.py 主入口更新环境,再以 exec 替换为真实应用进程。"""
181
+ root = Path(__file__).resolve().parents[1]
182
+ os.chdir(root)
183
  cache = Path(
184
  os.getenv(
185
  "MINERU_RUNTIME_CACHE", str(Path.home() / ".cache/mineru-space-runtime")
 
190
  os.environ["PATH"] = f"{environment / 'bin'}:{os.environ.get('PATH', '')}"
191
  os.execv(
192
  str(environment / "bin/python"),
193
+ [str(environment / "bin/python"), "-m", "mineru_space.space_app"],
194
  )
pdf_sidecar.py → mineru_space/pdf_sidecar.py RENAMED
File without changes
runtime-requirements.txt → mineru_space/runtime-requirements.txt RENAMED
@@ -6,4 +6,4 @@ docvortex==0.4.15
6
  numpy==2.2.6
7
  pydantic==2.12.5
8
  transformers==5.17.0
9
- onnxruntime-gpu==1.29.0
 
6
  numpy==2.2.6
7
  pydantic==2.12.5
8
  transformers==5.17.0
9
+ onnxruntime==1.29.0
runtime_preflight.py → mineru_space/runtime_preflight.py RENAMED
@@ -9,19 +9,15 @@ import sys
9
 
10
 
11
  def main(commit: str) -> None:
12
- """校验安装来源和应用依赖,显式认可 GPU 发行包提供 onnxruntime 导入模块。"""
13
  from packaging.requirements import Requirement
14
  from packaging.utils import canonicalize_name
15
 
16
- if sys.version_info[:2] != (3, 12):
17
- raise RuntimeError("The application runtime must use Python 3.12")
18
-
19
  installed = {
20
  canonicalize_name(d.metadata["Name"]): d for d in metadata.distributions()
21
  }
22
- if "onnxruntime" in installed:
23
- raise RuntimeError("CPU and GPU ONNX Runtime distributions must not coexist")
24
- assert metadata.version("onnxruntime-gpu") == "1.29.0"
25
  info = json.loads(
26
  metadata.distribution("mineru").read_text("direct_url.json") or "{}"
27
  )
@@ -40,8 +36,6 @@ def main(commit: str) -> None:
40
  ):
41
  continue
42
  name = canonicalize_name(requirement.name)
43
- if name == "onnxruntime":
44
- name = "onnxruntime-gpu"
45
  found = installed.get(name)
46
  if found is None or (
47
  requirement.specifier and found.version not in requirement.specifier
@@ -58,8 +52,8 @@ def main(commit: str) -> None:
58
  from mineru.parser import parse
59
 
60
  assert spaces is not None and callable(table_ort_session)
61
- assert torch.version.cuda and torch.version.cuda.startswith("13.")
62
- assert "CUDAExecutionProvider" in ort.get_available_providers()
63
  assert callable(images._load_images_from_pdf_worker)
64
  assert "max_pages" in inspect.signature(build_gradio_app).parameters
65
  assert {"tier", "ocr_mode", "page_range"} <= set(
@@ -79,7 +73,7 @@ def main(commit: str) -> None:
79
  "spaces",
80
  "gradio",
81
  "docvortex",
82
- "onnxruntime-gpu",
83
  ]
84
  },
85
  "cuda": torch.version.cuda,
 
9
 
10
 
11
  def main(commit: str) -> None:
12
+ """校验提交来源、常规依赖和 CPU ORT,确保候选满足应用运行条件。"""
13
  from packaging.requirements import Requirement
14
  from packaging.utils import canonicalize_name
15
 
 
 
 
16
  installed = {
17
  canonicalize_name(d.metadata["Name"]): d for d in metadata.distributions()
18
  }
19
+ if "onnxruntime-gpu" in installed or "onnxruntime" not in installed:
20
+ raise RuntimeError("The Space requires CPU onnxruntime without onnxruntime-gpu")
 
21
  info = json.loads(
22
  metadata.distribution("mineru").read_text("direct_url.json") or "{}"
23
  )
 
36
  ):
37
  continue
38
  name = canonicalize_name(requirement.name)
 
 
39
  found = installed.get(name)
40
  if found is None or (
41
  requirement.specifier and found.version not in requirement.specifier
 
52
  from mineru.parser import parse
53
 
54
  assert spaces is not None and callable(table_ort_session)
55
+ assert torch.version.cuda, "ZeroGPU requires a CUDA build of Torch"
56
+ assert "CPUExecutionProvider" in ort.get_available_providers()
57
  assert callable(images._load_images_from_pdf_worker)
58
  assert "max_pages" in inspect.signature(build_gradio_app).parameters
59
  assert {"tier", "ocr_mode", "page_range"} <= set(
 
73
  "spaces",
74
  "gradio",
75
  "docvortex",
76
+ "onnxruntime",
77
  ]
78
  },
79
  "cuda": torch.version.cuda,
space_app.py → mineru_space/space_app.py RENAMED
@@ -13,8 +13,8 @@ from concurrent.futures import ProcessPoolExecutor
13
  from pathlib import Path
14
  from typing import Any, Callable
15
 
16
- import pdf_sidecar
17
- from space_runtime import (
18
  configure_environment,
19
  gpu_task_entry,
20
  log_event,
 
13
  from pathlib import Path
14
  from typing import Any, Callable
15
 
16
+ from . import pdf_sidecar
17
+ from .space_runtime import (
18
  configure_environment,
19
  gpu_task_entry,
20
  log_event,
space_runtime.py → mineru_space/space_runtime.py RENAMED
@@ -42,18 +42,16 @@ def timed(event: str, **fields: Any) -> Iterator[None]:
42
  def configure_environment() -> None:
43
  """主进程在导入 spaces/Torch/MinerU 前建立统一运行配置。"""
44
  os.environ["MINERU_DEVICE_MODE"] = "cuda"
 
45
  os.environ["GRADIO_SSR_MODE"] = "False"
46
  for name, value in {
47
  "MINERU_MODEL_SOURCE": "huggingface",
48
  "MINERU_INTRA_OP_NUM_THREADS": "4",
49
  "MINERU_INTER_OP_NUM_THREADS": "1",
50
- "MINERU_TABLE_DEVICE": "cpu",
51
  "MINERU_PROFILE_STAGES": "1",
52
  "MINERU_ZEROGPU_DURATION": "60",
53
  }.items():
54
  os.environ.setdefault(name, value)
55
- if os.environ["MINERU_TABLE_DEVICE"] not in {"cpu", "cuda"}:
56
- raise ValueError("MINERU_TABLE_DEVICE must be cpu or cuda")
57
 
58
 
59
  def preload_torch_models() -> None:
 
42
  def configure_environment() -> None:
43
  """主进程在导入 spaces/Torch/MinerU 前建立统一运行配置。"""
44
  os.environ["MINERU_DEVICE_MODE"] = "cuda"
45
+ os.environ["MINERU_TABLE_DEVICE"] = "cpu"
46
  os.environ["GRADIO_SSR_MODE"] = "False"
47
  for name, value in {
48
  "MINERU_MODEL_SOURCE": "huggingface",
49
  "MINERU_INTRA_OP_NUM_THREADS": "4",
50
  "MINERU_INTER_OP_NUM_THREADS": "1",
 
51
  "MINERU_PROFILE_STAGES": "1",
52
  "MINERU_ZEROGPU_DURATION": "60",
53
  }.items():
54
  os.environ.setdefault(name, value)
 
 
55
 
56
 
57
  def preload_torch_models() -> None:
tests/test_bootstrap.py DELETED
@@ -1,112 +0,0 @@
1
- """用隔离缓存验证更新事务,失败不会删除或替换上次可用环境。"""
2
-
3
- import json
4
- import pytest
5
-
6
- import bootstrap
7
-
8
-
9
- def prepare(tmp_path):
10
- """构造可运行缓存记录与最小配置,不安装实际依赖。"""
11
- root, cache = tmp_path / "app", tmp_path / "cache"
12
- root.mkdir()
13
- cache.mkdir()
14
- (root / "runtime-requirements.txt").write_text("test\n")
15
- (root / "runtime_preflight.py").write_text("test\n")
16
- environment = cache / "old"
17
- (environment / "bin").mkdir(parents=True)
18
- (environment / "bin/python").touch()
19
- state = {"environment": "old", "commit": "a" * 40, "fingerprint": "old"}
20
- bootstrap.write_json(environment / "ready.json", state)
21
- bootstrap.write_json(cache / "current.json", state)
22
- return root, cache, environment, state
23
-
24
-
25
- @pytest.mark.parametrize("failure", ["network", "installation"])
26
- def test_failure_keeps_last_working_environment(monkeypatch, tmp_path, failure):
27
- """网络或安装失败均保留当前清单,并在回退前验证旧环境。"""
28
- root, cache, environment, state = prepare(tmp_path)
29
- validations = []
30
-
31
- def fail(*args, **kwargs):
32
- """模拟候选更新失败。"""
33
- raise RuntimeError("offline")
34
-
35
- monkeypatch.setattr(
36
- bootstrap, "dev_head", fail if failure == "network" else lambda: "b" * 40
37
- )
38
- monkeypatch.setattr(bootstrap, "build_candidate", fail)
39
- monkeypatch.setattr(bootstrap, "validate", lambda *args: validations.append(args))
40
- assert bootstrap.select_environment(root, cache) == environment
41
- assert json.loads((cache / "current.json").read_text()) == state
42
- assert validations == [(root, environment, "a" * 40)]
43
-
44
-
45
- def test_no_cache_fails_clearly(monkeypatch, tmp_path):
46
- """没有可用缓存时不能假装更新成功。"""
47
- root, cache, _, _ = prepare(tmp_path)
48
- (cache / "current.json").unlink()
49
- monkeypatch.setattr(bootstrap, "dev_head", lambda: "b" * 40)
50
-
51
- def fail(*args):
52
- """模拟首次安装失败。"""
53
- raise RuntimeError("install failed")
54
-
55
- monkeypatch.setattr(bootstrap, "build_candidate", fail)
56
- with pytest.raises(RuntimeError, match="No verified"):
57
- bootstrap.select_environment(root, cache)
58
-
59
-
60
- def test_activation_preserves_previous(monkeypatch, tmp_path):
61
- """只有候选构建成功后才替换 current,并保存上一份完整清单。"""
62
- root, cache, old, state = prepare(tmp_path)
63
- new = cache / "new"
64
- new_state = {"environment": "new", "commit": "b" * 40, "fingerprint": "new"}
65
- monkeypatch.setattr(bootstrap, "dev_head", lambda: "b" * 40)
66
- monkeypatch.setattr(bootstrap, "build_candidate", lambda *a: (new, new_state))
67
- assert bootstrap.select_environment(root, cache) == new
68
- assert json.loads((cache / "previous.json").read_text()) == state
69
- assert json.loads((cache / "current.json").read_text()) == new_state
70
- assert (old / "bin/python").is_file()
71
-
72
-
73
- def test_rejects_environment_outside_cache(tmp_path):
74
- """损坏清单不能指向缓存以外的 Python 环境。"""
75
- _, cache, _, _ = prepare(tmp_path)
76
- bootstrap.write_json(cache / "current.json", {"environment": "../outside"})
77
- assert bootstrap.active_environment(cache) is None
78
-
79
-
80
- def test_cleanup_only_removes_owned_retired_environments(tmp_path):
81
- """清理仅作用于带成功标记的历史环境,保留当前版本和其他文件。"""
82
- current = tmp_path / ("py312-" + "a" * 12 + "-" + "1" * 8)
83
- retired = tmp_path / ("py312-" + "b" * 12 + "-" + "2" * 8)
84
- unrelated = tmp_path / "notes"
85
- for directory in (current, retired, unrelated):
86
- directory.mkdir()
87
- bootstrap.write_json(directory / "ready.json", {"environment": directory.name})
88
- bootstrap.prune_environments(tmp_path, {current.name})
89
- assert current.is_dir() and unrelated.is_dir() and not retired.exists()
90
-
91
-
92
- def test_candidate_python_does_not_follow_launcher(monkeypatch, tmp_path):
93
- """HF devmode 的系统 Python 可能固定为 3.10,候选环境必须显式选择 3.12。"""
94
- commands = []
95
-
96
- def fake_run(command, **kwargs):
97
- """模拟环境创建,仅保存命令和 Python 路径用于验证安装边界。"""
98
- commands.append(command)
99
- if "venv" in command:
100
- environment = tmp_path / command[-1]
101
- (environment / "bin").mkdir(parents=True)
102
- (environment / "bin/python").touch()
103
- return ""
104
-
105
- monkeypatch.setattr(bootstrap, "run", fake_run)
106
- monkeypatch.setattr(bootstrap, "validate", lambda *args: None)
107
- environment, _ = bootstrap.build_candidate(tmp_path, tmp_path, "a" * 40, "test")
108
- assert commands[0][commands[0].index("--python") + 1] == "3.12"
109
- assert environment.name.startswith("py312-")
110
- assert any(
111
- f"@{'a' * 40}" in argument for command in commands for argument in command
112
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_space.py DELETED
@@ -1,244 +0,0 @@
1
- """验证 GPU 分流、spawn 无副作用与取消后的真实任务串行边界。"""
2
-
3
- import asyncio
4
- import subprocess
5
- import sys
6
- import threading
7
- import time
8
- import multiprocessing
9
- from contextvars import ContextVar
10
- from pathlib import Path
11
-
12
- import pytest
13
-
14
- import pdf_sidecar
15
- import space_app
16
- from space_runtime import gpu_deadline
17
-
18
-
19
- def slow_gpu_worker():
20
- """模拟卡住的独立推理进程,验证看门狗不会结束测试主进程。"""
21
- with gpu_deadline(0.1, "test-timeout"):
22
- time.sleep(5)
23
-
24
-
25
- def test_watchdog_terminates_only_daemon_worker():
26
- """超时 worker 以明确状态退出,父进程仍能继续处理其他任务。"""
27
- process = multiprocessing.get_context("spawn").Process(
28
- target=slow_gpu_worker, daemon=True
29
- )
30
- process.start()
31
- process.join(timeout=4)
32
- try:
33
- assert process.exitcode == 124
34
- finally:
35
- if process.is_alive():
36
- process.terminate()
37
- process.join()
38
-
39
-
40
- @pytest.mark.parametrize(
41
- "tier,mode,gpu",
42
- [
43
- ("flash", "txt", False),
44
- ("flash", "ocr", True),
45
- ("basic", "txt", True),
46
- ("basic", "ocr", True),
47
- ("basic", "auto", True),
48
- ],
49
- )
50
- def test_route_matrix(monkeypatch, tier, mode, gpu):
51
- """显式模式与 Basic 均不分类,防止 TXT 请求意外触发 OCR。"""
52
- from mineru.parser import file_type
53
-
54
- monkeypatch.setattr(file_type, "guess_suffix_by_path", lambda path: "pdf")
55
- monkeypatch.setattr(
56
- pdf_sidecar,
57
- "classify_pdf",
58
- lambda *args: pytest.fail("unexpected classification"),
59
- )
60
- assert pdf_sidecar.resolve_route("input.pdf", tier, mode, "").use_gpu is gpu
61
-
62
-
63
- @pytest.mark.parametrize("mode", ["txt", "ocr"])
64
- def test_auto_is_resolved_once(monkeypatch, mode):
65
- """分类结果直接成为后续 parse 的显式模式。"""
66
- from mineru.parser import file_type
67
-
68
- calls = []
69
- monkeypatch.setattr(file_type, "guess_suffix_by_path", lambda path: "pdf")
70
- monkeypatch.setattr(
71
- pdf_sidecar, "classify_pdf", lambda *args: calls.append(args) or mode
72
- )
73
- route = pdf_sidecar.resolve_route("input.pdf", "flash", "auto", "2-4")
74
- assert route == pdf_sidecar.ParseRoute("flash", mode, mode == "ocr")
75
- assert calls == [("input.pdf", "2-4")]
76
-
77
-
78
- @pytest.mark.parametrize("suffix", ["docx", "ofd", "html", "csv", "epub"])
79
- def test_native_formats_stay_cpu(monkeypatch, suffix):
80
- """即使传入残留 Basic/OCR 选项,原生格式仍为 Flash CPU。"""
81
- from mineru.parser import file_type
82
-
83
- monkeypatch.setattr(file_type, "guess_suffix_by_path", lambda path: suffix)
84
- assert pdf_sidecar.resolve_route(
85
- "input", "basic", "ocr", ""
86
- ) == pdf_sidecar.ParseRoute("flash", "txt", False)
87
-
88
-
89
- def test_image_auto_routes_gpu(monkeypatch):
90
- """图片 auto 无需打开 PDF 即能确定 OCR 路由。"""
91
- from mineru.parser import file_type
92
-
93
- monkeypatch.setattr(file_type, "guess_suffix_by_path", lambda path: "png")
94
- assert pdf_sidecar.resolve_route("input", "flash", "auto", "").use_gpu
95
-
96
-
97
- def test_cpu_entry_rejects_models():
98
- """CPU sidecar 在导入 parser 前拒绝模型路径。"""
99
- with pytest.raises(ValueError, match="Flash TXT"):
100
- pdf_sidecar.parse_cpu(
101
- "missing.pdf", tier="basic", ocr_mode="txt", page_range=""
102
- )
103
-
104
-
105
- def test_mixed_pdf_classifies_selected_pages(tmp_path):
106
- """混合 PDF 的文本页与空白页选区分别固定为 TXT/OCR,不能按整本文档误分流。"""
107
- from reportlab.pdfgen.canvas import Canvas
108
-
109
- path = tmp_path / "mixed.pdf"
110
- canvas = Canvas(str(path))
111
- for row in range(20):
112
- canvas.drawString(
113
- 40,
114
- 780 - row * 25,
115
- "This document contains selectable text for native PDF extraction.",
116
- )
117
- canvas.showPage()
118
- canvas.showPage()
119
- canvas.save()
120
- assert pdf_sidecar.classify_pdf(str(path), "1") == "txt"
121
- assert pdf_sidecar.classify_pdf(str(path), "2") == "ocr"
122
-
123
-
124
- def test_twenty_page_limit_is_enforced(tmp_path):
125
- """真实 21 页输入默认截到 20 页,显式超限范围在 GPU 调用前被拒绝。"""
126
- from reportlab.pdfgen.canvas import Canvas
127
- from mineru.errors import InvalidRequestError
128
-
129
- path = tmp_path / "long.pdf"
130
- canvas = Canvas(str(path))
131
- for _ in range(21):
132
- canvas.drawString(40, 100, "Page")
133
- canvas.showPage()
134
- canvas.save()
135
- assert pdf_sidecar.resolve_page_range(path, "", max_pages=20) == "1-20"
136
- with pytest.raises(InvalidRequestError):
137
- pdf_sidecar.resolve_page_range(path, "1-21", max_pages=20)
138
-
139
-
140
- def test_import_has_no_heavy_or_update_side_effects():
141
- """全新解释器导入 spawn 入口不会导入模型、PDFium 或执行启动更新。"""
142
- root = Path(__file__).resolve().parents[1]
143
- subprocess.run(
144
- [
145
- sys.executable,
146
- "-c",
147
- "import sys, app, space_app, pdf_sidecar; "
148
- "assert not {'mineru', 'torch', 'spaces', 'docvortex', 'bootstrap'} & sys.modules.keys()",
149
- ],
150
- cwd=root,
151
- check=True,
152
- timeout=20,
153
- )
154
-
155
-
156
- def test_sidecar_device_is_cpu(monkeypatch):
157
- """sidecar 初始化强制清除从主进程继承的两个 CUDA 选择。"""
158
- monkeypatch.setenv("MINERU_DEVICE_MODE", "cuda")
159
- monkeypatch.setenv("MINERU_TABLE_DEVICE", "cuda")
160
- pdf_sidecar.initialize()
161
- import os
162
-
163
- assert (
164
- os.environ["MINERU_DEVICE_MODE"] == os.environ["MINERU_TABLE_DEVICE"] == "cpu"
165
- )
166
-
167
-
168
- def test_flash_txt_never_calls_gpu(monkeypatch):
169
- """经客户端实际分派的 Flash TXT 不调用装饰后的 GPU 函数。"""
170
-
171
- def fake_sidecar(fn, *args, **kwargs):
172
- """返回预定 CPU 路由,并核实 parse 不会再次使用 auto。"""
173
- if fn is pdf_sidecar.resolve_route:
174
- return pdf_sidecar.ParseRoute("flash", "txt", False)
175
- assert fn is pdf_sidecar.parse_cpu and kwargs["ocr_mode"] == "txt"
176
- return "cpu result"
177
-
178
- monkeypatch.setattr(space_app, "sidecar_call", fake_sidecar)
179
- client = space_app.InProcessArtifactClient(
180
- lambda *args, **kwargs: pytest.fail("GPU entered")
181
- )
182
- assert (
183
- asyncio.run(
184
- client.parse_file("input.pdf", tier="flash", ocr_mode="auto", page_range="")
185
- )
186
- == "cpu result"
187
- )
188
-
189
-
190
- def test_cancellation_keeps_slot_and_context(monkeypatch):
191
- """重复取消不能放行第二个 GPU 请求,to_thread 仍传播原请求上下文。"""
192
- started, release = threading.Event(), threading.Event()
193
- calls = []
194
- visitor = ContextVar("visitor", default="missing")
195
- monkeypatch.setattr(
196
- space_app,
197
- "sidecar_call",
198
- lambda *a, **k: pdf_sidecar.ParseRoute("basic", "txt", True),
199
- )
200
-
201
- def gpu(*args, **kwargs):
202
- """模拟无法被 asyncio 取消的同步 GPU 推理。"""
203
- calls.append(visitor.get())
204
- started.set()
205
- assert release.wait(5)
206
- return "done"
207
-
208
- async def scenario():
209
- """取消第一个调用后,第二个调用必须等待真正的同步推理结束。"""
210
- visitor.set("visitor-1")
211
- client = space_app.InProcessArtifactClient(gpu)
212
- first = asyncio.create_task(client.parse_file("a", tier="basic", page_range=""))
213
- assert await asyncio.to_thread(started.wait, 3)
214
- first.cancel()
215
- await asyncio.sleep(0)
216
- first.cancel()
217
- second = asyncio.create_task(
218
- client.parse_file("b", tier="basic", page_range="")
219
- )
220
- try:
221
- await asyncio.sleep(0.05)
222
- assert calls == ["visitor-1"] and not first.done() and not second.done()
223
- finally:
224
- release.set()
225
- with pytest.raises(asyncio.CancelledError):
226
- await first
227
- assert await second == "done"
228
- assert calls == ["visitor-1", "visitor-1"]
229
-
230
- asyncio.run(scenario())
231
-
232
-
233
- def test_parse_errors_are_wrapped(monkeypatch):
234
- """真正解析阶段的错误统一转换为客户端错误,而非只包围 return。"""
235
- from mineru.kit.gradio.client import V1ArtifactError
236
-
237
- def fail(*args, **kwargs):
238
- """模拟分类期间 PDFium 抛出的解析错误。"""
239
- raise RuntimeError("broken PDF")
240
-
241
- monkeypatch.setattr(space_app, "sidecar_call", fail)
242
- client = space_app.InProcessArtifactClient()
243
- with pytest.raises(V1ArtifactError, match="broken PDF"):
244
- asyncio.run(client.parse_file("input", tier="basic", page_range=""))