feat(onnx): automatically select CPU or DirectML devices

Add auto/cpu/gpu configuration, Windows adapter discovery, isolated rec calibration, atomic preference persistence, and vendor session presets. Route layout, rec, and full MFR batches through DirectML with CPU fallback.

Use mutually exclusive Windows x64 DirectML dependencies and guard ORT/Magika distribution identity. Include calibration fixtures, three-machine results, the 0.8 threshold with ten UHD 770 repeats, and installation documentation.

Validation: 762 MinerU tests and 71 DocVortex tests passed; dependency checks and Windows Python 3.10-3.14 resolution passed. The 19-page OCR result matches the CPU baseline. DocVortex PyPI acceptance remains pending a compatible release; pinned source acceptance passed.
This commit is contained in:
myhloli
2026-10-19 11:02:29 +08:00
parent c3d34f379f
commit 48524bfabb
42 changed files with 3652 additions and 70 deletions
@@ -0,0 +1,461 @@
{
"date": "2026-10-18",
"scope": "Windows x64 installation acceptance using pinned DocVortex source; cross-platform resolution and wheel checks only",
"docvortex": {
"version": "0.5.16",
"repository": "https://github.com/myhloli/DocVortex",
"commit": "545570c4bffa2a668b7db03b5d3117e7a13faf8c",
"native_extension": false,
"source_dirty_before_build": false
},
"packages": {
"mineru": "4.0.12",
"docvortex": "0.5.16",
"mineru-magika": "1.0.3",
"mineru-vl-utils": "2.0.7",
"onnxruntime-directml": "1.24.4"
},
"python": "3.14.7 (tags/v3.14.7:823f032, Aug 5 2026, 10:51:32) [MSC v.1944 64 bit (AMD64)]",
"owners": {
"magika": [
"mineru-magika"
],
"onnxruntime": [
"onnxruntime-directml"
]
},
"ort": {
"version": "1.24.4",
"path": "D:\\projects\\20260914magicpdf\\Magic-PDF\\.venv\\Lib\\site-packages\\onnxruntime\\__init__.py",
"providers": [
"DmlExecutionProvider",
"CPUExecutionProvider"
]
},
"wheel_sha256": {
"docvortex-0.5.16-py3-none-any.whl": "94cde0320fac4be4af474cac78adc33bb57affc2d9d2aa56a02ba263169b785b",
"mineru-4.0.12-py3-none-any.whl": "199ac6229cf443672dc16f49d6e377cbdd599d4702cb25274529090ea5f38eb0",
"mineru_magika-1.0.3-py3-none-any.whl": "1b67e05769f48c28d4d5dda0869ece4f4acce769ec7ab6ed169ed34ee5b58343",
"mineru_vl_utils-2.0.7-py3-none-any.whl": "3bf184c359d69e78d0509f58324c58ad323037cf97bd4086cacb9caf53db0db7"
},
"pip_check": {
"success": true,
"checked_packages": 159,
"installation_resolved_packages": 121
},
"tests": {
"mineru": 762,
"docvortex": 71,
"warnings": "Two third-party deprecations in MinerU suite; no failures"
},
"rec": {
"threshold": 0.8,
"new_processes": 3,
"validation_passed": true,
"runs": [
{
"cpu_elapsed": 0.259271000046283,
"gpu_elapsed": 0.14191849995404482,
"ratio": 0.5473751400222572,
"selected": "gpu",
"outputs_match": true,
"ort_version": "1.24.4",
"ort_path": "D:\\projects\\20260914magicpdf\\Magic-PDF\\.venv\\Lib\\site-packages\\onnxruntime\\__init__.py",
"gpu_providers": [
"DmlExecutionProvider",
"CPUExecutionProvider"
]
},
{
"cpu_elapsed": 0.26189600001089275,
"gpu_elapsed": 0.13717320002615452,
"ratio": 0.5237697407384962,
"selected": "gpu",
"outputs_match": true,
"ort_version": "1.24.4",
"ort_path": "D:\\projects\\20260914magicpdf\\Magic-PDF\\.venv\\Lib\\site-packages\\onnxruntime\\__init__.py",
"gpu_providers": [
"DmlExecutionProvider",
"CPUExecutionProvider"
]
},
{
"cpu_elapsed": 0.262668399955146,
"gpu_elapsed": 0.1418222999200225,
"ratio": 0.53992905101733,
"selected": "gpu",
"outputs_match": true,
"ort_version": "1.24.4",
"ort_path": "D:\\projects\\20260914magicpdf\\Magic-PDF\\.venv\\Lib\\site-packages\\onnxruntime\\__init__.py",
"gpu_providers": [
"DmlExecutionProvider",
"CPUExecutionProvider"
]
}
]
},
"native": {
"html_type": "html",
"html_pages": 1,
"html_text_preserved": true,
"pdf_pages": 1,
"pdf_markdown_characters": 71977,
"html_execution": {
"tier": "flash",
"parse_mode": "txt"
},
"pdf_execution": {
"tier": "flash",
"parse_mode": "txt"
}
},
"document": {
"pages": 19,
"wall_s": 145.46046650002245,
"comparison": {
"pages_equal": true,
"markdown_text_equal": true,
"markdown_bytes_equal_with_matching_serialization": true,
"baseline_newline": "CRLF",
"initial_newline": "LF",
"baseline_sha256": "6fa409c5986b5207a4cadff8d106a063c977180758babe615fc5ce58ebd86b49",
"current_lf_sha256": "7e541eba607dc12b2d2a6aa48ed5041be926db9f66d855f45e32c494b6fb6418",
"current_crlf_sha256": "6fa409c5986b5207a4cadff8d106a063c977180758babe615fc5ce58ebd86b49",
"note": "Initial helper used write_bytes while the baseline helper used Windows write_text; only newline serialization differed. The comparison preserves the original LF output and writes a separate CRLF artifact."
},
"sessions": [
{
"key": "('onnx', 'cpu', ('ocr', 0.5, 'ch', 1.5, True), OnnxSelection(device='gpu', adapter=GpuAdapter(index=0, luid='00000000:0000f3be', name='Intel(R) Arc(TM) 130T GPU (16GB)', vendor_id=32902, device_id=32081, uma=None, preference=0), reason='configured_gpu'))",
"name": "text_recognizer",
"providers": [
"DmlExecutionProvider",
"CPUExecutionProvider"
]
},
{
"key": "('onnx', 'cpu', ('ocr', 0.5, 'ch', 1.5, True), OnnxSelection(device='gpu', adapter=GpuAdapter(index=0, luid='00000000:0000f3be', name='Intel(R) Arc(TM) 130T GPU (16GB)', vendor_id=32902, device_id=32081, uma=None, preference=0), reason='configured_gpu'))",
"name": "text_detector",
"providers": [
"CPUExecutionProvider"
]
},
{
"key": "('onnx', 'cpu', ('layout', 'cpu'), OnnxSelection(device='gpu', adapter=GpuAdapter(index=0, luid='00000000:0000f3be', name='Intel(R) Arc(TM) 130T GPU (16GB)', vendor_id=32902, device_id=32081, uma=None, preference=0), reason='configured_gpu'))",
"name": "session",
"providers": [
"DmlExecutionProvider",
"CPUExecutionProvider"
]
},
{
"key": "('onnx', 'cpu', ('ocr', 0.5, 'ch', 1.6, False), OnnxSelection(device='gpu', adapter=GpuAdapter(index=0, luid='00000000:0000f3be', name='Intel(R) Arc(TM) 130T GPU (16GB)', vendor_id=32902, device_id=32081, uma=None, preference=0), reason='configured_gpu'))",
"name": "text_recognizer",
"providers": [
"DmlExecutionProvider",
"CPUExecutionProvider"
]
},
{
"key": "('onnx', 'cpu', ('ocr', 0.5, 'ch', 1.6, False), OnnxSelection(device='gpu', adapter=GpuAdapter(index=0, luid='00000000:0000f3be', name='Intel(R) Arc(TM) 130T GPU (16GB)', vendor_id=32902, device_id=32081, uma=None, preference=0), reason='configured_gpu'))",
"name": "text_detector",
"providers": [
"CPUExecutionProvider"
]
},
{
"key": "('onnx', 'cpu', ('mfr', 'cpu'), OnnxSelection(device='gpu', adapter=GpuAdapter(index=0, luid='00000000:0000f3be', name='Intel(R) Arc(TM) 130T GPU (16GB)', vendor_id=32902, device_id=32081, uma=None, preference=0), reason='configured_gpu'))",
"name": "session",
"providers": [
"DmlExecutionProvider",
"CPUExecutionProvider"
]
},
{
"key": "('onnx', 'cpu', 'table_cls', OnnxSelection(device='gpu', adapter=GpuAdapter(index=0, luid='00000000:0000f3be', name='Intel(R) Arc(TM) 130T GPU (16GB)', vendor_id=32902, device_id=32081, uma=None, preference=0), reason='configured_gpu'))",
"name": "sess",
"providers": [
"CPUExecutionProvider"
]
}
]
},
"platform_matrix": [
{
"platform": "linux",
"python": "3.10",
"extra": "base",
"success": true,
"resolver_succeeded": true,
"identity": [
"mineru-magika==1.0.3",
"onnxruntime==1.23.2"
],
"errors": [],
"diagnostics": ""
},
{
"platform": "linux",
"python": "3.11",
"extra": "base",
"success": true,
"resolver_succeeded": true,
"identity": [
"mineru-magika==1.0.3",
"onnxruntime==1.31.0"
],
"errors": [],
"diagnostics": ""
},
{
"platform": "linux",
"python": "3.12",
"extra": "base",
"success": true,
"resolver_succeeded": true,
"identity": [
"mineru-magika==1.0.3",
"onnxruntime==1.31.0"
],
"errors": [],
"diagnostics": ""
},
{
"platform": "linux",
"python": "3.13",
"extra": "base",
"success": true,
"resolver_succeeded": true,
"identity": [
"mineru-magika==1.0.3",
"onnxruntime==1.31.0"
],
"errors": [],
"diagnostics": ""
},
{
"platform": "linux",
"python": "3.14",
"extra": "base",
"success": true,
"resolver_succeeded": true,
"identity": [
"mineru-magika==1.0.3",
"onnxruntime==1.31.0"
],
"errors": [],
"diagnostics": ""
},
{
"platform": "macos-intel",
"python": "3.10",
"extra": "base",
"success": false,
"resolver_succeeded": true,
"identity": [
"mineru-magika==1.0.3",
"onnxruntime==1.23.2"
],
"errors": [
"cryptography==50.0.2: no wheel for macos-intel Python 3.10"
],
"diagnostics": ""
},
{
"platform": "macos-intel",
"python": "3.11",
"extra": "base",
"success": false,
"resolver_succeeded": true,
"identity": [
"mineru-magika==1.0.3",
"onnxruntime==1.23.2"
],
"errors": [
"cryptography==50.0.2: no wheel for macos-intel Python 3.11"
],
"diagnostics": ""
},
{
"platform": "macos-intel",
"python": "3.12",
"extra": "base",
"success": false,
"resolver_succeeded": true,
"identity": [
"mineru-magika==1.0.3",
"onnxruntime==1.23.2"
],
"errors": [
"cryptography==50.0.2: no wheel for macos-intel Python 3.12"
],
"diagnostics": ""
},
{
"platform": "macos-intel",
"python": "3.13",
"extra": "base",
"success": false,
"resolver_succeeded": true,
"identity": [
"mineru-magika==1.0.3",
"onnxruntime==1.23.2"
],
"errors": [
"cryptography==50.0.2: no wheel for macos-intel Python 3.13"
],
"diagnostics": ""
},
{
"platform": "macos-intel",
"python": "3.14",
"extra": "base",
"success": false,
"resolver_succeeded": false,
"identity": [],
"errors": [],
"diagnostics": " × No solution found when resolving dependencies:\n ╰─▶ Because onnxruntime{(platform_machine != 'AMD64' and platform_machine\n != 'amd64' and platform_machine != 'x86_64') or sys_platform !=\n 'win32'}>=1.20.1,<=1.23.2 has no wheels with a matching Python\n ABI tag (e.g., `cp314`) and only the following versions of\n onnxruntime{(platform_machine != 'AMD64' and platform_machine !=\n 'amd64' and platform_machine != 'x86_64') or sys_platform != 'win32'}\n are available:\n onnxruntime{(platform_machine != 'AMD64' and platform_machine\n != 'amd64' and platform_machine != 'x86_64') or sys_platform !=\n 'win32'}<=1.23.2\n onnxruntime{(platform_machine != 'AMD64' and platform_machine\n != 'amd64' and platform_machine != 'x86_64') or sys_platform !=\n 'win32'}>=1.24.1\n we can conclude that onnxruntime{(platform_machine != 'AMD64' and\n platform_machine != 'amd64' and platform_machine != 'x86_64') or\n sys_platform != 'win32'}>=1.20.1,<1.24.1 cannot be used.\n And because onnxruntime{(platform_machine != 'AMD64' and\n platform_machine != 'amd64' and platform_machine != 'x86_64') or\n sys_platform != 'win32'}>=1.24.1 has no wheels with a matching\n platform tag (e.g., `macosx_14_0_x86_64`) and mineru==4.0.12 depends on\n onnxruntime{(platform_machine != 'AMD64' and platform_machine != 'amd64'\n and platform_machine != 'x86_64') or sys_platform != 'win32'}>=1.20.1,\n we can conclude that mineru==4.0.12 cannot be used.\n And because only mineru==4.0.12 is available and you require mineru, we\n can conclude that your requirements are unsatisfiable.\n\nhint: You require CPython 3.14 (`cp314`), but we only found wheels for `onnxruntime` (v1.23.2) with the following Python ABI tags: `cp310`, `cp311`, `cp312`, `cp313`, `cp313t`\nhint: Wheels are available for `onnxruntime` (v1.31.0) on the following platforms: `manylinux_2_28_aarch64`, `manylinux_2_28_x86_64`, `macosx_14_0_arm64`, `win_amd64`, `win_arm64`\n"
},
{
"platform": "macos",
"python": "3.10",
"extra": "base",
"success": true,
"resolver_succeeded": true,
"identity": [
"mineru-magika==1.0.3",
"onnxruntime==1.23.2"
],
"errors": [],
"diagnostics": ""
},
{
"platform": "macos",
"python": "3.11",
"extra": "base",
"success": true,
"resolver_succeeded": true,
"identity": [
"mineru-magika==1.0.3",
"onnxruntime==1.31.0"
],
"errors": [],
"diagnostics": ""
},
{
"platform": "macos",
"python": "3.12",
"extra": "base",
"success": true,
"resolver_succeeded": true,
"identity": [
"mineru-magika==1.0.3",
"onnxruntime==1.31.0"
],
"errors": [],
"diagnostics": ""
},
{
"platform": "macos",
"python": "3.13",
"extra": "base",
"success": true,
"resolver_succeeded": true,
"identity": [
"mineru-magika==1.0.3",
"onnxruntime==1.31.0"
],
"errors": [],
"diagnostics": ""
},
{
"platform": "macos",
"python": "3.14",
"extra": "base",
"success": true,
"resolver_succeeded": true,
"identity": [
"mineru-magika==1.0.3",
"onnxruntime==1.31.0"
],
"errors": [],
"diagnostics": ""
},
{
"platform": "windows",
"python": "3.10",
"extra": "base",
"success": true,
"resolver_succeeded": true,
"identity": [
"mineru-magika==1.0.3",
"onnxruntime-directml==1.23.0"
],
"errors": [],
"diagnostics": ""
},
{
"platform": "windows",
"python": "3.11",
"extra": "base",
"success": true,
"resolver_succeeded": true,
"identity": [
"mineru-magika==1.0.3",
"onnxruntime-directml==1.24.4"
],
"errors": [],
"diagnostics": ""
},
{
"platform": "windows",
"python": "3.12",
"extra": "base",
"success": true,
"resolver_succeeded": true,
"identity": [
"mineru-magika==1.0.3",
"onnxruntime-directml==1.24.4"
],
"errors": [],
"diagnostics": ""
},
{
"platform": "windows",
"python": "3.13",
"extra": "base",
"success": true,
"resolver_succeeded": true,
"identity": [
"mineru-magika==1.0.3",
"onnxruntime-directml==1.24.4"
],
"errors": [],
"diagnostics": ""
},
{
"platform": "windows",
"python": "3.14",
"extra": "base",
"success": true,
"resolver_succeeded": true,
"identity": [
"mineru-magika==1.0.3",
"onnxruntime-directml==1.24.4"
],
"errors": [],
"diagnostics": ""
}
],
"pypi_default_control": {
"accepted": false,
"selected": [
"docvortex==0.5.16",
"magika==1.0.3",
"onnxruntime==1.31.0",
"onnxruntime-directml==1.24.4"
],
"reason": "PyPI docvortex 0.5.16 still has old CPU ORT and upstream magika requirements; use pinned compatible source until a new compatible release and minimum-version update"
},
"raw_evidence_directory": "output\\onnx-dependency-acceptance-20261018",
"source_install_used_no_deps": false
}
@@ -0,0 +1,368 @@
{
"date": "2026-10-18",
"threshold": 0.8,
"label": "i7",
"cpu": "12th Gen Intel(R) Core(TM) i7-12700",
"gpu": "Intel(R) UHD Graphics 770",
"requested_runs": 10,
"successful_runs": 10,
"cpu_selections": 10,
"gpu_selections": 0,
"errors": [],
"validation_passed": true,
"minimum_ratio": 1.541681982052094,
"maximum_ratio": 1.8595392617371458,
"median_ratio": 1.6057012217187245,
"signature": [
"5d6728644b7f9ba220b3072eb00b6d436e774f32f3d30cddd06f0017042f4375",
"474fa06cbc4c20303081e85084b45f3a4f24190d1141e4bd3bf3f3feb432c078",
"5435fd747c9e0efe15a96d0b378d5bd157e9492ed8fd80edf08f30d02fa24634",
"1.24.4",
4,
1
],
"source_report_sha256": "2f10d1a93488413a44fa2c4012ebca6ccfd513ef593ad6ee50cfd7decad98f63",
"protocol": "10 fresh workers; CPU first, GPU second; one warm-up and two timed batch6 runs per device; 4/1 threads; Intel rec metacommands off",
"runs": [
{
"run": 1,
"cpu": {
"init_s": 0.08625900000333786,
"first_s": 0.15661530010402203,
"timed_s": [
0.16551060043275356,
0.1512667997740209
],
"providers": [
"CPUExecutionProvider"
]
},
"gpu": {
"init_s": 0.18019469967111945,
"first_s": 0.37250009970739484,
"timed_s": [
0.24737749993801117,
0.2507206997834146
],
"providers": [
"DmlExecutionProvider",
"CPUExecutionProvider"
]
},
"cpu_elapsed": 0.3167774002067745,
"gpu_elapsed": 0.49809819972142577,
"ratio": 1.572391841704286,
"selected": "cpu",
"worker_wall_s": 2.3538882001303136,
"gpu_first": false,
"outputs_match": true,
"rec_metacommands": false
},
{
"run": 2,
"cpu": {
"init_s": 0.11205330025404692,
"first_s": 0.22100889962166548,
"timed_s": [
0.16745080007240176,
0.15029080025851727
],
"providers": [
"CPUExecutionProvider"
]
},
"gpu": {
"init_s": 0.4032955998554826,
"first_s": 0.3545626001432538,
"timed_s": [
0.24505490018054843,
0.2448015999980271
],
"providers": [
"DmlExecutionProvider",
"CPUExecutionProvider"
]
},
"cpu_elapsed": 0.317741600330919,
"gpu_elapsed": 0.4898565001785755,
"ratio": 1.541681982052094,
"selected": "cpu",
"worker_wall_s": 2.664180700201541,
"gpu_first": false,
"outputs_match": true,
"rec_metacommands": false
},
{
"run": 3,
"cpu": {
"init_s": 0.07879989966750145,
"first_s": 0.1900048996321857,
"timed_s": [
0.16707619978114963,
0.1503589996136725
],
"providers": [
"CPUExecutionProvider"
]
},
"gpu": {
"init_s": 0.22637039981782436,
"first_s": 0.3442545998841524,
"timed_s": [
0.24778660014271736,
0.24555819993838668
],
"providers": [
"DmlExecutionProvider",
"CPUExecutionProvider"
]
},
"cpu_elapsed": 0.3174351993948221,
"gpu_elapsed": 0.49334480008110404,
"ratio": 1.5541590882852523,
"selected": "cpu",
"worker_wall_s": 2.4069735002703965,
"gpu_first": false,
"outputs_match": true,
"rec_metacommands": false
},
{
"run": 4,
"cpu": {
"init_s": 0.07987920008599758,
"first_s": 0.17749949963763356,
"timed_s": [
0.15440709982067347,
0.13619509991258383
],
"providers": [
"CPUExecutionProvider"
]
},
"gpu": {
"init_s": 0.18289580009877682,
"first_s": 0.45124789979308844,
"timed_s": [
0.27159929974004626,
0.2687869002111256
],
"providers": [
"DmlExecutionProvider",
"CPUExecutionProvider"
]
},
"cpu_elapsed": 0.2906021997332573,
"gpu_elapsed": 0.5403861999511719,
"ratio": 1.8595392617371458,
"selected": "cpu",
"worker_wall_s": 2.593133999966085,
"gpu_first": false,
"outputs_match": true,
"rec_metacommands": false
},
{
"run": 5,
"cpu": {
"init_s": 0.08565740007907152,
"first_s": 0.16779650002717972,
"timed_s": [
0.1658973996527493,
0.14534990023821592
],
"providers": [
"CPUExecutionProvider"
]
},
"gpu": {
"init_s": 0.16685859998688102,
"first_s": 0.3460075999610126,
"timed_s": [
0.2489708000794053,
0.24423420010134578
],
"providers": [
"DmlExecutionProvider",
"CPUExecutionProvider"
]
},
"cpu_elapsed": 0.3112472998909652,
"gpu_elapsed": 0.4932050001807511,
"ratio": 1.5846081246440644,
"selected": "cpu",
"worker_wall_s": 2.5043258001096547,
"gpu_first": false,
"outputs_match": true,
"rec_metacommands": false
},
{
"run": 6,
"cpu": {
"init_s": 0.08326179999858141,
"first_s": 0.17670980002731085,
"timed_s": [
0.14763120003044605,
0.1476646000519395
],
"providers": [
"CPUExecutionProvider"
]
},
"gpu": {
"init_s": 0.16335829999297857,
"first_s": 0.3053096001967788,
"timed_s": [
0.24584809970110655,
0.24588420009240508
],
"providers": [
"DmlExecutionProvider",
"CPUExecutionProvider"
]
},
"cpu_elapsed": 0.29529580008238554,
"gpu_elapsed": 0.49173229979351163,
"ratio": 1.6652194161119855,
"selected": "cpu",
"worker_wall_s": 2.276292699854821,
"gpu_first": false,
"outputs_match": true,
"rec_metacommands": false
},
{
"run": 7,
"cpu": {
"init_s": 0.08156939968466759,
"first_s": 0.15990540012717247,
"timed_s": [
0.1556295999325812,
0.1378607996739447
],
"providers": [
"CPUExecutionProvider"
]
},
"gpu": {
"init_s": 0.1639045001938939,
"first_s": 0.3187303002923727,
"timed_s": [
0.24700330011546612,
0.2433102000504732
],
"providers": [
"DmlExecutionProvider",
"CPUExecutionProvider"
]
},
"cpu_elapsed": 0.2934903996065259,
"gpu_elapsed": 0.49031350016593933,
"ratio": 1.6706287525019166,
"selected": "cpu",
"worker_wall_s": 2.2545465999282897,
"gpu_first": false,
"outputs_match": true,
"rec_metacommands": false
},
{
"run": 8,
"cpu": {
"init_s": 0.08818410011008382,
"first_s": 0.15725370030850172,
"timed_s": [
0.146055500023067,
0.16311899991706014
],
"providers": [
"CPUExecutionProvider"
]
},
"gpu": {
"init_s": 0.16608940018340945,
"first_s": 0.30329359974712133,
"timed_s": [
0.2454309999011457,
0.24593059998005629
],
"providers": [
"DmlExecutionProvider",
"CPUExecutionProvider"
]
},
"cpu_elapsed": 0.30917449994012713,
"gpu_elapsed": 0.491361599881202,
"ratio": 1.5892694901305124,
"selected": "cpu",
"worker_wall_s": 2.2385326996445656,
"gpu_first": false,
"outputs_match": true,
"rec_metacommands": false
},
{
"run": 9,
"cpu": {
"init_s": 0.08204400027170777,
"first_s": 0.17115489998832345,
"timed_s": [
0.1495567001402378,
0.14376969961449504
],
"providers": [
"CPUExecutionProvider"
]
},
"gpu": {
"init_s": 0.16184849990531802,
"first_s": 0.3028835002332926,
"timed_s": [
0.2472366001456976,
0.2436722000129521
],
"providers": [
"DmlExecutionProvider",
"CPUExecutionProvider"
]
},
"cpu_elapsed": 0.29332639975473285,
"gpu_elapsed": 0.4909088001586497,
"ratio": 1.6735922868488036,
"selected": "cpu",
"worker_wall_s": 2.2591197001747787,
"gpu_first": false,
"outputs_match": true,
"rec_metacommands": false
},
{
"run": 10,
"cpu": {
"init_s": 0.0922516998834908,
"first_s": 0.16064579971134663,
"timed_s": [
0.14966769982129335,
0.15491799963638186
],
"providers": [
"CPUExecutionProvider"
]
},
"gpu": {
"init_s": 0.16616430040448904,
"first_s": 0.3052006997168064,
"timed_s": [
0.2473430000245571,
0.2467355001717806
],
"providers": [
"DmlExecutionProvider",
"CPUExecutionProvider"
]
},
"cpu_elapsed": 0.3045856994576752,
"gpu_elapsed": 0.4940785001963377,
"ratio": 1.6221329533069366,
"selected": "cpu",
"worker_wall_s": 2.2529193996451795,
"gpu_first": false,
"outputs_match": true,
"rec_metacommands": false
}
]
}
@@ -0,0 +1,899 @@
{
"threshold": 0.69639683662382,
"lower": 0.5508244759786391,
"upper": 0.841969197269001,
"ratios": {
"ultra5": [
0.5222368732328241,
0.5508244759786391,
0.5295334203545369,
0.4991853924531031,
0.5114943474992194
],
"i7": [
1.5809789058054133,
1.5428846664602596,
1.666585902822754,
0.841969197269001,
1.6714340091642932
],
"max395": [
0.4016474016753725,
0.3651728945628206,
0.3828735159099384,
0.3297758941471135,
0.33356088170696885
]
},
"signature": [
"5d6728644b7f9ba220b3072eb00b6d436e774f32f3d30cddd06f0017042f4375",
"474fa06cbc4c20303081e85084b45f3a4f24190d1141e4bd3bf3f3feb432c078",
"5435fd747c9e0efe15a96d0b378d5bd157e9492ed8fd80edf08f30d02fa24634",
"1.24.4",
4,
1
],
"status": "validated",
"date": "2026-10-18",
"frozen_bundle_sha256": "094760d45f0907964fad82a697ed54322c0fbcdaaa39f09304885bfa70d69781",
"platforms": [
{
"label": "ultra5",
"cpu": "Intel(R) Core(TM) Ultra 5 225H",
"gpu": "Intel(R) Arc(TM) 130T GPU (16GB)",
"vendor_id": 32902,
"device_id": 32081,
"uma": true,
"display_drivers": [
{
"DriverDesc": "Intel(R) Arc(TM) 130T GPU (16GB)",
"DriverVersion": "32.0.101.8991",
"MatchingDeviceId": "PCI\\VEN_8086&DEV_7D51"
},
{
"DriverDesc": "GameViewer Virtual Display Adapter",
"DriverVersion": "15.6.5.199",
"MatchingDeviceId": "Root\\GameViewerIddDriver"
}
],
"selected": "gpu",
"calibration": [
{
"cpu": {
"init_s": 0.1245090999873355,
"first_s": 0.1557560000801459,
"timed_s": [
0.1557519999332726,
0.14047220000065863
],
"providers": [
"CPUExecutionProvider"
]
},
"gpu": {
"init_s": 4.168702200055122,
"first_s": 0.196261000004597,
"timed_s": [
0.0804380999179557,
0.07426110003143549
],
"providers": [
"DmlExecutionProvider",
"CPUExecutionProvider"
]
},
"cpu_elapsed": 0.29622419993393123,
"gpu_elapsed": 0.1546991999493912,
"ratio": 0.5222368732328241,
"worker_wall_s": 8.3898099999642,
"gpu_first": false,
"outputs_match": true,
"rec_metacommands": false
},
{
"cpu": {
"init_s": 0.07538569997996092,
"first_s": 0.16537980001885444,
"timed_s": [
0.15101999999023974,
0.14874009997583926
],
"providers": [
"CPUExecutionProvider"
]
},
"gpu": {
"init_s": 4.063421499915421,
"first_s": 0.13587660004850477,
"timed_s": [
0.08014570001978427,
0.08496949996333569
],
"providers": [
"DmlExecutionProvider",
"CPUExecutionProvider"
]
},
"cpu_elapsed": 0.299760099966079,
"gpu_elapsed": 0.16511519998311996,
"ratio": 0.5508244759786391,
"worker_wall_s": 8.340767999994569,
"gpu_first": true,
"outputs_match": true,
"rec_metacommands": false
},
{
"cpu": {
"init_s": 0.09954189998097718,
"first_s": 0.15099960006773472,
"timed_s": [
0.14387050003279,
0.1338656999869272
],
"providers": [
"CPUExecutionProvider"
]
},
"gpu": {
"init_s": 4.0616575999883935,
"first_s": 0.1588244999293238,
"timed_s": [
0.0761950999731198,
0.07087549997959286
],
"providers": [
"DmlExecutionProvider",
"CPUExecutionProvider"
]
},
"cpu_elapsed": 0.2777362000197172,
"gpu_elapsed": 0.14707059995271266,
"ratio": 0.5295334203545369,
"worker_wall_s": 8.172107200021856,
"gpu_first": false,
"outputs_match": true,
"rec_metacommands": false
},
{
"cpu": {
"init_s": 0.06927199999336153,
"first_s": 0.16087769996374846,
"timed_s": [
0.15035170002374798,
0.14322520000860095
],
"providers": [
"CPUExecutionProvider"
]
},
"gpu": {
"init_s": 4.0834626000141725,
"first_s": 0.12122359999921173,
"timed_s": [
0.07379920000676066,
0.07275010005105287
],
"providers": [
"DmlExecutionProvider",
"CPUExecutionProvider"
]
},
"cpu_elapsed": 0.29357690003234893,
"gpu_elapsed": 0.14654930005781353,
"ratio": 0.4991853924531031,
"worker_wall_s": 8.232045500073582,
"gpu_first": true,
"outputs_match": true,
"rec_metacommands": false
},
{
"cpu": {
"init_s": 0.09695310005918145,
"first_s": 0.1524447000119835,
"timed_s": [
0.1417236999841407,
0.1385096999583766
],
"providers": [
"CPUExecutionProvider"
]
},
"gpu": {
"init_s": 4.022490600007586,
"first_s": 0.15835319994948804,
"timed_s": [
0.07324170006904751,
0.07009609998203814
],
"providers": [
"DmlExecutionProvider",
"CPUExecutionProvider"
]
},
"cpu_elapsed": 0.2802333999425173,
"gpu_elapsed": 0.14333780005108565,
"ratio": 0.5114943474992194,
"worker_wall_s": 8.233562199980952,
"gpu_first": false,
"outputs_match": true,
"rec_metacommands": false
}
],
"validation": [
{
"cpu": {
"init_s": 0.10164109990000725,
"first_s": 0.14734019991010427,
"timed_s": [
0.14145549992099404,
0.13380010006949306
],
"providers": [
"CPUExecutionProvider"
]
},
"gpu": {
"init_s": 4.08250819996465,
"first_s": 0.14972859993577003,
"timed_s": [
0.07329309999477118,
0.07348050002474338
],
"providers": [
"DmlExecutionProvider",
"CPUExecutionProvider"
]
},
"cpu_elapsed": 0.2752555999904871,
"gpu_elapsed": 0.14677360001951456,
"ratio": 0.5332265720464437,
"worker_wall_s": 8.254710600012913,
"gpu_first": false,
"outputs_match": true,
"rec_metacommands": false
},
{
"cpu": {
"init_s": 0.10452659998554736,
"first_s": 0.15435500000603497,
"timed_s": [
0.14770820003468543,
0.15267079998739064
],
"providers": [
"CPUExecutionProvider"
]
},
"gpu": {
"init_s": 4.437392899999395,
"first_s": 0.21258459996897727,
"timed_s": [
0.08509210008196533,
0.07600710005499423
],
"providers": [
"DmlExecutionProvider",
"CPUExecutionProvider"
]
},
"cpu_elapsed": 0.30037900002207607,
"gpu_elapsed": 0.16109920013695955,
"ratio": 0.536319783091094,
"worker_wall_s": 9.278964000055566,
"gpu_first": false,
"outputs_match": true,
"rec_metacommands": false
},
{
"cpu": {
"init_s": 0.10005200002342463,
"first_s": 0.16607539996039122,
"timed_s": [
0.15235289989504963,
0.1595705000218004
],
"providers": [
"CPUExecutionProvider"
]
},
"gpu": {
"init_s": 4.254619500017725,
"first_s": 0.16254559997469187,
"timed_s": [
0.07519969996064901,
0.07101000007241964
],
"providers": [
"DmlExecutionProvider",
"CPUExecutionProvider"
]
},
"cpu_elapsed": 0.31192339991685003,
"gpu_elapsed": 0.14620970003306866,
"ratio": 0.46873591424062455,
"worker_wall_s": 8.56839440006297,
"gpu_first": false,
"outputs_match": true,
"rec_metacommands": false
}
]
},
{
"label": "i7",
"cpu": "12th Gen Intel(R) Core(TM) i7-12700",
"gpu": "Intel(R) UHD Graphics 770",
"vendor_id": 32902,
"device_id": 18048,
"uma": true,
"display_drivers": [
{
"DriverDesc": "GameViewer Virtual Display Adapter",
"DriverVersion": "15.6.5.199",
"MatchingDeviceId": "Root\\GameViewerIddDriver"
},
{
"DriverDesc": "Intel(R) UHD Graphics 770",
"DriverVersion": "32.0.101.6078",
"MatchingDeviceId": "PCI\\VEN_8086&DEV_4680"
},
{
"DriverDesc": "NVIDIA GeForce RTX 3060 Ti",
"DriverVersion": "32.0.16.1060",
"MatchingDeviceId": "pci\\ven_10de&dev_2489&subsys_c9761028"
},
{
"DriverDesc": "Microsoft Remote Display Adapter",
"DriverVersion": "10.0.22000.1042",
"MatchingDeviceId": "RdpIdd_IndirectDisplay"
},
{
"DriverDesc": "Microsoft Remote Display Adapter",
"DriverVersion": "10.0.22000.1042",
"MatchingDeviceId": "RdpIdd_IndirectDisplay"
}
],
"selected": "cpu",
"calibration": [
{
"cpu": {
"init_s": 0.12509390013292432,
"first_s": 0.16786340018734336,
"timed_s": [
0.16060509998351336,
0.15400769980624318
],
"providers": [
"CPUExecutionProvider"
]
},
"gpu": {
"init_s": 0.20541869988664985,
"first_s": 0.4193283002823591,
"timed_s": [
0.24820070015266538,
0.2491954998113215
],
"providers": [
"DmlExecutionProvider",
"CPUExecutionProvider"
]
},
"cpu_elapsed": 0.31461279978975654,
"gpu_elapsed": 0.4973961999639869,
"ratio": 1.5809789058054133,
"worker_wall_s": 2.5029639997519553,
"gpu_first": false,
"outputs_match": true,
"rec_metacommands": false
},
{
"cpu": {
"init_s": 0.06983949989080429,
"first_s": 0.15956749999895692,
"timed_s": [
0.16224139975383878,
0.15460029989480972
],
"providers": [
"CPUExecutionProvider"
]
},
"gpu": {
"init_s": 0.19752479996532202,
"first_s": 0.36972759990021586,
"timed_s": [
0.24553100019693375,
0.243319199886173
],
"providers": [
"DmlExecutionProvider",
"CPUExecutionProvider"
]
},
"cpu_elapsed": 0.3168416996486485,
"gpu_elapsed": 0.48885020008310676,
"ratio": 1.5428846664602596,
"worker_wall_s": 2.576211799867451,
"gpu_first": true,
"outputs_match": true,
"rec_metacommands": false
},
{
"cpu": {
"init_s": 0.08222260000184178,
"first_s": 0.16638420009985566,
"timed_s": [
0.1508931997232139,
0.1421357998624444
],
"providers": [
"CPUExecutionProvider"
]
},
"gpu": {
"init_s": 0.17086009960621595,
"first_s": 0.30164070008322597,
"timed_s": [
0.24523840006440878,
0.243119599763304
],
"providers": [
"DmlExecutionProvider",
"CPUExecutionProvider"
]
},
"cpu_elapsed": 0.2930289995856583,
"gpu_elapsed": 0.4883579998277128,
"ratio": 1.666585902822754,
"worker_wall_s": 2.242871499620378,
"gpu_first": false,
"outputs_match": true,
"rec_metacommands": false
},
{
"cpu": {
"init_s": 0.07191150030121207,
"first_s": 0.17400800017639995,
"timed_s": [
0.16089960001409054,
0.42469319980591536
],
"providers": [
"CPUExecutionProvider"
]
},
"gpu": {
"init_s": 0.17822950007393956,
"first_s": 0.28638230031356215,
"timed_s": [
0.24899799982085824,
0.24405309977009892
],
"providers": [
"DmlExecutionProvider",
"CPUExecutionProvider"
]
},
"cpu_elapsed": 0.5855927998200059,
"gpu_elapsed": 0.49305109959095716,
"ratio": 0.841969197269001,
"worker_wall_s": 2.648738599848002,
"gpu_first": true,
"outputs_match": true,
"rec_metacommands": false
},
{
"cpu": {
"init_s": 0.07968680001795292,
"first_s": 0.15783980023115873,
"timed_s": [
0.14695069985464215,
0.1442038998939097
],
"providers": [
"CPUExecutionProvider"
]
},
"gpu": {
"init_s": 0.16532819997519255,
"first_s": 0.2991844001226127,
"timed_s": [
0.24408889980986714,
0.24255680013448
],
"providers": [
"DmlExecutionProvider",
"CPUExecutionProvider"
]
},
"cpu_elapsed": 0.29115459974855185,
"gpu_elapsed": 0.48664569994434714,
"ratio": 1.6714340091642932,
"worker_wall_s": 2.2790574999526143,
"gpu_first": false,
"outputs_match": true,
"rec_metacommands": false
}
],
"validation": [
{
"cpu": {
"init_s": 0.07856390019878745,
"first_s": 0.1585060004144907,
"timed_s": [
0.155076100025326,
0.13724670000374317
],
"providers": [
"CPUExecutionProvider"
]
},
"gpu": {
"init_s": 0.19285160023719072,
"first_s": 0.3793517001904547,
"timed_s": [
0.2471511997282505,
0.25015190010890365
],
"providers": [
"DmlExecutionProvider",
"CPUExecutionProvider"
]
},
"cpu_elapsed": 0.2923228000290692,
"gpu_elapsed": 0.49730309983715415,
"ratio": 1.701212152414048,
"worker_wall_s": 2.3880711998790503,
"gpu_first": false,
"outputs_match": true,
"rec_metacommands": false
},
{
"cpu": {
"init_s": 0.0931462999433279,
"first_s": 0.18240519985556602,
"timed_s": [
0.16190369985997677,
0.14276310009881854
],
"providers": [
"CPUExecutionProvider"
]
},
"gpu": {
"init_s": 0.17211529985070229,
"first_s": 0.33837390039116144,
"timed_s": [
0.24955720035359263,
0.24976880010217428
],
"providers": [
"DmlExecutionProvider",
"CPUExecutionProvider"
]
},
"cpu_elapsed": 0.3046667999587953,
"gpu_elapsed": 0.4993260004557669,
"ratio": 1.6389248862143768,
"worker_wall_s": 2.320380700286478,
"gpu_first": false,
"outputs_match": true,
"rec_metacommands": false
},
{
"cpu": {
"init_s": 0.13239890011027455,
"first_s": 0.27217369992285967,
"timed_s": [
0.172813200391829,
0.16785890003666282
],
"providers": [
"CPUExecutionProvider"
]
},
"gpu": {
"init_s": 0.18517510034143925,
"first_s": 0.37796449987217784,
"timed_s": [
0.244259400293231,
0.24512009974569082
],
"providers": [
"DmlExecutionProvider",
"CPUExecutionProvider"
]
},
"cpu_elapsed": 0.34067210042849183,
"gpu_elapsed": 0.48937950003892183,
"ratio": 1.4365118230209877,
"worker_wall_s": 2.5700910999439657,
"gpu_first": false,
"outputs_match": true,
"rec_metacommands": false
}
]
},
{
"label": "max395",
"cpu": "AMD RYZEN AI MAX+ PRO 395 w/ Radeon 8060S",
"gpu": "AMD Radeon(TM) 8060S Graphics",
"vendor_id": 4098,
"device_id": 5510,
"uma": true,
"display_drivers": [
{
"DriverDesc": "AMD Radeon(TM) 8060S Graphics",
"DriverVersion": "32.0.31041.1004",
"MatchingDeviceId": "PCI\\VEN_1002&DEV_1586&SUBSYS_8D01103C&REV_D1"
},
{
"DriverDesc": "GameViewer Virtual Display Adapter",
"DriverVersion": "15.6.5.199",
"MatchingDeviceId": "Root\\GameViewerIddDriver"
}
],
"selected": "gpu",
"calibration": [
{
"cpu": {
"init_s": 0.07637480000266805,
"first_s": 0.08280470001045614,
"timed_s": [
0.07512400002451614,
0.07167690002825111
],
"providers": [
"CPUExecutionProvider"
]
},
"gpu": {
"init_s": 0.34370189998298883,
"first_s": 0.11481369996909052,
"timed_s": [
0.03170540003338829,
0.027256800036411732
],
"providers": [
"DmlExecutionProvider",
"CPUExecutionProvider"
]
},
"cpu_elapsed": 0.14680090005276725,
"gpu_elapsed": 0.05896220006980002,
"ratio": 0.4016474016753725,
"worker_wall_s": 1.8912332000327297,
"gpu_first": false,
"outputs_match": true,
"rec_metacommands": true
},
{
"cpu": {
"init_s": 0.057475099980365485,
"first_s": 0.09697800001595169,
"timed_s": [
0.09229130001040176,
0.07979649998014793
],
"providers": [
"CPUExecutionProvider"
]
},
"gpu": {
"init_s": 0.33477970003150403,
"first_s": 0.08208980003837496,
"timed_s": [
0.031072899990249425,
0.03176890005124733
],
"providers": [
"DmlExecutionProvider",
"CPUExecutionProvider"
]
},
"cpu_elapsed": 0.17208779999054968,
"gpu_elapsed": 0.06284180004149675,
"ratio": 0.3651728945628206,
"worker_wall_s": 1.9066087000537664,
"gpu_first": true,
"outputs_match": true,
"rec_metacommands": true
},
{
"cpu": {
"init_s": 0.07782819995190948,
"first_s": 0.08145880000665784,
"timed_s": [
0.07784209999954328,
0.07104310003342107
],
"providers": [
"CPUExecutionProvider"
]
},
"gpu": {
"init_s": 0.30829120002454147,
"first_s": 0.10129029996460304,
"timed_s": [
0.030917699972633272,
0.02608650003094226
],
"providers": [
"DmlExecutionProvider",
"CPUExecutionProvider"
]
},
"cpu_elapsed": 0.14888520003296435,
"gpu_elapsed": 0.057004200003575534,
"ratio": 0.3828735159099384,
"worker_wall_s": 1.7865404000040144,
"gpu_first": false,
"outputs_match": true,
"rec_metacommands": true
},
{
"cpu": {
"init_s": 0.05741999996826053,
"first_s": 0.08883979998063296,
"timed_s": [
0.07954020000761375,
0.07562760001746938
],
"providers": [
"CPUExecutionProvider"
]
},
"gpu": {
"init_s": 0.33454459998756647,
"first_s": 0.08503759995801374,
"timed_s": [
0.025020400003995746,
0.02615019999211654
],
"providers": [
"DmlExecutionProvider",
"CPUExecutionProvider"
]
},
"cpu_elapsed": 0.15516780002508312,
"gpu_elapsed": 0.05117059999611229,
"ratio": 0.3297758941471135,
"worker_wall_s": 1.8132058000192046,
"gpu_first": true,
"outputs_match": true,
"rec_metacommands": true
},
{
"cpu": {
"init_s": 0.0747986999922432,
"first_s": 0.0824259000364691,
"timed_s": [
0.08045999996829778,
0.07203510002000257
],
"providers": [
"CPUExecutionProvider"
]
},
"gpu": {
"init_s": 0.31970799999544397,
"first_s": 0.09892600000603124,
"timed_s": [
0.02595470001688227,
0.02491169999120757
],
"providers": [
"DmlExecutionProvider",
"CPUExecutionProvider"
]
},
"cpu_elapsed": 0.15249509998830035,
"gpu_elapsed": 0.05086640000808984,
"ratio": 0.33356088170696885,
"worker_wall_s": 1.7935570999979973,
"gpu_first": false,
"outputs_match": true,
"rec_metacommands": true
}
],
"validation": [
{
"cpu": {
"init_s": 0.07893840002361685,
"first_s": 0.08305060002021492,
"timed_s": [
0.07627840002533048,
0.07216009998228401
],
"providers": [
"CPUExecutionProvider"
]
},
"gpu": {
"init_s": 0.32700849999673665,
"first_s": 0.11254589998861775,
"timed_s": [
0.03159369999775663,
0.026833700016140938
],
"providers": [
"DmlExecutionProvider",
"CPUExecutionProvider"
]
},
"cpu_elapsed": 0.1484385000076145,
"gpu_elapsed": 0.05842740001389757,
"ratio": 0.39361351678237383,
"worker_wall_s": 1.872372500016354,
"gpu_first": false,
"outputs_match": true,
"rec_metacommands": true
},
{
"cpu": {
"init_s": 0.07521159999305382,
"first_s": 0.08401260001119226,
"timed_s": [
0.0768831999739632,
0.07127350004157051
],
"providers": [
"CPUExecutionProvider"
]
},
"gpu": {
"init_s": 0.31867100001545623,
"first_s": 0.10151509998831898,
"timed_s": [
0.0292708000051789,
0.025711999973282218
],
"providers": [
"DmlExecutionProvider",
"CPUExecutionProvider"
]
},
"cpu_elapsed": 0.14815670001553372,
"gpu_elapsed": 0.05498279997846112,
"ratio": 0.3711124773479455,
"worker_wall_s": 1.8174999000038952,
"gpu_first": false,
"outputs_match": true,
"rec_metacommands": true
},
{
"cpu": {
"init_s": 0.07662479998543859,
"first_s": 0.0823513999930583,
"timed_s": [
0.08154410001588985,
0.07119129999773577
],
"providers": [
"CPUExecutionProvider"
]
},
"gpu": {
"init_s": 0.3092622999683954,
"first_s": 0.10270039999159053,
"timed_s": [
0.029992799973115325,
0.02598440001020208
],
"providers": [
"DmlExecutionProvider",
"CPUExecutionProvider"
]
},
"cpu_elapsed": 0.15273540001362562,
"gpu_elapsed": 0.055977199983317405,
"ratio": 0.36649787788766486,
"worker_wall_s": 1.8085962000186555,
"gpu_first": false,
"outputs_match": true,
"rec_metacommands": true
}
]
}
]
}
+4
View File
@@ -10,6 +10,10 @@
## 1. 定位
ONNX 小模型新增 `model.onnx_device: auto | cpu | gpu`,环境变量为
`MINERU_MODEL_ONNX_DEVICE`。首次实际使用时自动选择并保存,独显优先;
集显测速、重新选择及 DirectML 厂商预设见 [ONNX 设备选择](onnx-device.md)。
配置体系定义 MinerU 如何在不同启动阶段得到最终行为。
MinerU 有两类配置:
+94
View File
@@ -0,0 +1,94 @@
# ONNX 依赖验收(2026-10-18)
Windows x64 的完整安装验收通过,使用项目 `.venv`、Python 3.14.7 和 PowerShell 7。
本轮使用已发布的 `mineru-magika` 与固定提交构建的 DocVortex,未使用 `--no-deps`、修改已安装元数据或依赖忽略规则。
可复核结果见 [验收数据](benchmarks/onnx-dependency-20261018.json)。
## 安装来源与发行包身份
| 发行包 | 验收版本 / 来源 |
|---|---|
| MinerU | 本工作区构建的 `4.0.12` wheel;项目 `.venv` 安装为 editable |
| DocVortex | `0.5.16`,提交 [`545570c4bffa2a668b7db03b5d3117e7a13faf8c`](https://github.com/myhloli/DocVortex/commit/545570c4bffa2a668b7db03b5d3117e7a13faf8c) |
| mineru-magika | PyPI `1.0.3`,wheel SHA256 `1b67e05769f48c28d4d5dda0869ece4f4acce769ec7ab6ed169ed34ee5b58343` |
| onnxruntime-directml | PyPI `1.24.4` |
| mineru-vl-utils | PyPI `2.0.7` |
DocVortex 从干净的固定提交构建纯 Python wheel,`DOCVORTEX_BUILD_NATIVE=0`,与此前开发环境一致;
本报告不包含 Rust 扩展构建验收。其他 wheel 的 SHA256 均记录在验收数据中。
迁移先卸载旧 `magika`,随后运行带完整依赖解析的:
```powershell
.\.venv\Scripts\uv.exe pip install --python .venv/Scripts/python.exe --index-url https://pypi.org/simple -e ".[dev,test]" output/onnx-dependency-acceptance-20261018/dist/docvortex-0.5.16-py3-none-any.whl
.\.venv\Scripts\uv.exe pip check --python .venv/Scripts/python.exe
```
安装解析了 121 个包,安装后的 159 个发行包通过 `uv pip check`。
发行包元数据与模块所有权检查确认:
- `onnxruntime` 模块只有 `onnxruntime-directml` 一个所有者;未安装 CPU 版 `onnxruntime`。
- `magika` 模块只有 `mineru-magika` 一个所有者;未安装旧 `magika`。
- `mineru-magika` 仅声明 `click` 依赖,没有任一种 ORT 依赖。
- 实际 ORT 位于项目 `.venv/Lib/site-packages/onnxruntime/__init__.py`,版本 `1.24.4`,
provider 为 `DmlExecutionProvider` 和 `CPUExecutionProvider`。
依赖检查脚本现同时拒绝两种 ORT 并存、旧 Magika 与新发行包并存,以及缺少新 Magika 的求解结果。
Windows x64 平台条件补充小写 `amd64`,与 DocVortex 保持一致;条件仍不区分 Python 版本。
## 回归与真实执行
- MinerU 的 ONNX、配置、路由、协议、文件类型与依赖检查:**762 passed**,仅两个第三方弃用警告。
- DocVortex 的安装元数据矩阵和真实文件 / 代码语言识别:**71 passed**。
真实 Magika 会话使用 DirectML 发行包提供的 CPU provider,线程为 `4/1`;惰性导入检查通过。
- 真实 HTML 分类、Flash HTML 原生解析和单页 PDF 原生文本解析通过。
- Ultra 5 / Arc 130T 使用新依赖运行三个独立 rec 验证进程:
| 进程 | CPU 两次累计 / 秒 | GPU 两次累计 / 秒 | ratio | 阈值 0.8 的选择 |
|---|---|---|---|---|
| 1 | 0.259271 | 0.141918 | 0.547375 | GPU |
| 2 | 0.261896 | 0.137173 | 0.523770 | GPU |
| 3 | 0.262668 | 0.141822 | 0.539929 | GPU |
三次均实际加载 DML provider,CPU/GPU 识别结果一致,没有超时或异常。
使用新依赖在新进程中完成 demo1+demo2 的全部 19 页 basic + 强制 OCR。
结果的完整 `pages` 数组与此前 CPU 基准完全相等,包括正文、公式、边界框、顺序和页面结构;
layout、rec、MFR 使用 DML,其他模型使用 CPU。
Markdown 文本也完全一致,统一为基准脚本的 Windows CRLF 写出方式后逐字节一致,SHA256 为
`6fa409c5986b5207a4cadff8d106a063c977180758babe615fc5ce58ebd86b49`。
本轮初始验收脚本使用 `write_bytes` 输出 LF,而基准使用 `write_text` 输出 CRLF,导致首次字节比较失败;
已确认差异仅为换行,修正脚本,并保留 LF 原件、CRLF 对照及独立比较记录。
## 平台求解与 wheel 覆盖
使用三个真实 wheel 从头求解基础安装依赖,并核对 PyPI 的 Python 范围和目标 ABI / 平台 wheel。
此处仅 Windows Python 3.14 执行了真实安装;其余格子是求解和发行文件检查,不代表在目标机器执行过安装或推理。
| 平台 | Python | 结果 | ORT / Magika 选择 |
|---|---|---|---|
| Windows x64 | 3.10 | 通过 | DirectML `1.23.0` + mineru-magika `1.0.3` |
| Windows x64 | 3.11–3.14 | 全部通过 | DirectML `1.24.4` + mineru-magika `1.0.3` |
| Linux x64 | 3.10–3.14 | 全部通过 | 仅 CPU ORT + mineru-magika `1.0.3` |
| Apple Silicon / macOS 14 | 3.10–3.14 | 全部通过 | 仅 CPU ORT + mineru-magika `1.0.3` |
| Intel Mac / macOS 14 | 3.10–3.13 | 求解通过;纯 wheel 检查失败 | 当前选出的 cryptography `50.0.2` 没有 Intel Mac wheel,源码构建未验收 |
| Intel Mac / macOS 14 | 3.14 | 求解失败 | ORT `1.23.2` 无 CPython 3.14 wheel,较新 ORT 无 Intel Mac wheel |
上述 Intel Mac 限制单独记录,未改变用户要求保留的其他平台依赖约束。
140 项 MinerU 平台 / Python / extra 标记测试及 DocVortex 安装元数据测试均确认 ORT 条件互斥。
并行检查还暴露了 Windows 下元数据缓存被另一进程读取时无法替换的竞争;检查脚本已改为复用已发布的完整缓存,
随后 Windows 和 Linux 的矩阵重跑通过。
## PyPI 发布边界
截至本轮检查,PyPI 的 DocVortex 仍为旧元数据的 `0.5.16`;兼容提交的源码版本号也仍是 `0.5.16`。
因此本轮通过的是**固定兼容源码的完整依赖验收**。
额外以普通 PyPI 依赖求解作对照,仍选出旧 `magika==1.0.3`、CPU `onnxruntime==1.31.0`
及 `onnxruntime-directml==1.24.4`,该组合明确判为不合格。
兼容版发布到 PyPI 后,需要提高 MinerU 的 DocVortex 最低版本,再验收不带源码覆盖的 PyPI 安装。
在此之前,按 [安装说明](onnx-device.md#安装) 显式指定固定提交。
原始构建产物、安装前后清单、求解输出、测试日志及推理结果保存在
`output/onnx-dependency-acceptance-20261018/`,未提交大型文档输出。
+114
View File
@@ -0,0 +1,114 @@
# ONNX 设备选择与 DirectML
`model.onnx_device` 控制 ONNX 小模型栈的 CPU / Windows DirectML 选择:
```yaml
model:
small_backend: onnx
onnx_device: auto
```
环境变量 `MINERU_MODEL_ONNX_DEVICE` 高于文件配置。`small_backend=auto` 仍按原有规则选择
Torch 或 ONNX;此选项不强制切换模型栈,也不改变 Torch / VLM 的设备。
| 值 | 行为 |
|---|---|
| `cpu` | ONNX 小模型全部使用 CPU,不枚举显卡、不测速 |
| `gpu` | 每个进程首次用模型时重新发现 GPU,独显优先,再选高性能排序中的集显,不测速 |
| `auto` | 独显直接选 GPU;仅有集显时执行一次 rec 测速;没有硬件 GPU 时选 CPU |
Windows 使用 DXGI 高性能顺序、D3D12 UMA 属性和 LUID 匹配;适配器编号不写入配置。
已保存的 `gpu` 使用 Windows 高性能排序及无设备创建的 D3D12 能力检查,避免逐张创建设备查询 UMA;
`auto` 才需要 UMA 分类。旧系统缺少高性能排序接口时仍执行分类以保证独显优先。
`gpu` 模式下移除显卡会在本次使用 CPU,仍保留配置中的 `gpu`。
非 Windows、驱动探测错误、运行时缺少 DML 或 GPU 会话初始化失败时,日志会说明 CPU 回退原因。
首次实际使用模型时才选型。界面启动、导入 SDK、查询配置和模型列表不会运行测速。
自动决策成功后原子更新有效配置文件中的 `model.onnx_device`,保留其余键;进程间通过文件锁协调。
环境变量覆盖不写入文件;只读配置保留本进程选择。测速失败不保存为 CPU 性能优胜,同进程不重复尝试。
通过环境变量强制 `auto` 时,每个新进程重新选择,并使用选择锁避免同时测速。
显式或已保存的 `cpu` 不会因后来接入独显而改变。
要重新测量,将配置改回 `auto` 并重启应用。修改程序阈值也不主动覆盖已保存的选择。
## 模型预设
GPU 路线只将 layout、rec、MFR 交给 DML,检测、表格等其他 ONNX 模型保持 CPU。
Torch 栈中的表格仍使用其原有 CPU/CUDA 策略。旧实验引擎和实验开关已经移除。
| 厂商 | layout | MFR | rec |
|---|---|---|---|
| Intel | 关 | 开 | 关 |
| AMD | 开 | 开 | 开 |
| NVIDIA | 开 | 开 | 关 |
| 其他 | ORT 默认 | ORT 默认 | ORT 默认 |
表格中的开关指 metacommands,关闭对应 `disable_metacommands=true`。
Intel layout 优先首轮响应;NVIDIA layout 采用后续补测结论。
MFR 只有实际批量为 8 时使用 DML,1–7 张使用惰性创建、复用的 CPU 会话,不补齐。
每个 DML 会话的 `run` 都串行化,不依赖宿主是否打开全局推理锁。
## 六图测速和阈值
图片和 manifest 位于包内 `resources/onnx_calibration`,包含原始中英数字句子、来源和 SHA256。
重新生成工具 `scripts.make_onnx_calibration_samples` 需要显式提供字体;部署时直接读取固定 PNG,
不需要系统字体。输入复用生产 rec 的 BGR、动态宽度和归一化逻辑;CPU/GPU 使用同一份 batch6。
每种设备预热一次,再计时两次。默认线程为 4/1;生产显式线程配置同时应用于测速。
比较 `ratio = gpu_elapsed / cpu_elapsed`,当 `ratio <= ONNX_GPU_TIME_RATIO_THRESHOLD` 时选 GPU。
阈值在 `mineru.model.runtime.onnx_device` 中集中维护。
当前阈值按用户要求简化为 **0.8**,即 GPU 推理耗时至少比 CPU 低 20%。
原三机校准推导值为 0.69639683662382,保留为历史参考;已有 24 次记录按 0.8 判断仍得到相同选择。
详细数据见 [三机校准报告](onnx-selection-validation.md)。
下载和设备发现不计入 worker 预算。独立隐藏子进程的总超时为 15 秒,包含解释器启动、加载及推理;
1–2 秒只是小样本推理目标。结果记录模型/输入校验值、ORT 版本与路径、provider、初始化、预热和正式计时。
CPU/GPU 解码 token 必须一致,整会话回退 CPU 或计时无效都视为失败。
### 发布前校准
在 Ultra 5 / Arc 130T、i7-12700 / UHD 770、MAX395 / 8060S 上使用相同代码、权重、样本和 ORT:
```powershell
.\.venv\Scripts\python.exe -m scripts.benchmark_onnx_selection devices
.\.venv\Scripts\python.exe -m scripts.benchmark_onnx_selection run --label ultra5 --luid <集显LUID> --output output/calibration-ultra5
```
另外两台使用 `--label i7` 和 `--label max395`。i7 必须在基准工具中明确选择 UHD 770,
应用的正常独显优先规则仍会选择 RTX 3060 Ti。输出目录必须不存在。
每台运行五个新进程,交替 CPU/GPU 先后顺序。汇总命令只接受完整且协议一致的三份报告:
```powershell
.\.venv\Scripts\python.exe -m scripts.benchmark_onnx_selection derive <ultra5报告> <i7报告> <max395报告> --output output/threshold.json
```
取 Ultra 5 / MAX395 所有比值的最大值为 L,取 i7 最小比值与 1.0 的较小值为 U。
只有 L < U 才给出中点 `(L + U) / 2` 作为统计建议;当前发布值按用户要求采用 0.8,
不自动用统计建议覆盖正式常量。随后各平台以 `run --threshold <待验证值>` 和新目录运行三个
独立验证进程,要求 Ultra 5、MAX395 全部选 GPU,i7 全部选 CPU。没有有效区间或验证失败时不发布阈值。
阈值为 `None` 表示三机校准尚未完成;此时自动集显路径使用 CPU、保留 `auto`,显式 GPU 及独显仍可使用。
## 安装
Windows x64 的直接依赖为 `onnxruntime-directml>=1.23,<2`,其他平台保留 CPU 包约束,
不按 Python 版本拆分条件。DirectML 包本身包含 CPU provider。
已有环境从 CPU 包迁移时,先卸载 `onnxruntime`,再安装 DirectML,避免两个发行包覆盖同一模块。
安装后用 `import onnxruntime; print(onnxruntime.__version__, onnxruntime.get_available_providers())` 检查实际能力。
文件识别使用 `mineru-magika>=1.0.3,<1.1.0`,Python 导入名仍为 `magika`,由 DocVortex 声明依赖。
迁移已有环境时须先卸载旧 `magika`,再安装新发行包,避免两个包覆盖同一模块。
2026-10-18 已使用 DocVortex 提交 `545570c4bffa2a668b7db03b5d3117e7a13faf8c` 完成
Windows x64 的完整依赖安装、发行包身份、真实分类、rec 与文档解析验收。
该提交的版本号仍为 `0.5.16`,当前 PyPI 同版本仍是旧依赖;因此验收使用固定提交构建的 wheel。
在兼容版本发布到 PyPI 并更新最低版本前,源码安装应显式指定该提交:
```powershell
.\.venv\Scripts\uv.exe pip install --python .venv/Scripts/python.exe -e . "docvortex @ git+https://github.com/myhloli/DocVortex.git@545570c4bffa2a668b7db03b5d3117e7a13faf8c"
.\.venv\Scripts\uv.exe pip check --python .venv/Scripts/python.exe
```
不能把两个 ORT 发行包或两个 Magika 发行包同时存在视为有效安装。
版本、校验值、平台矩阵与实测结果见 [依赖验收报告](onnx-dependency-validation.md)。
+107
View File
@@ -0,0 +1,107 @@
# ONNX 六图选型:三机校准与验收
日期:2026-10-18。当前正式阈值:**0.8**,按用户要求简化,GPU 推理耗时需至少降低 20%。
以下三机数据及中点计算保留为历史校准记录,当时使用的验收阈值为 0.69639683662382。
每台设备五个独立校准进程、三个独立验收进程,共 24 个新进程。
每个进程的 CPU/GPU 各运行一次预热和两次计时,输入为相同的六张中英数字图片。
使用 PP-OCRv6 small rec、DML 1.24.4、CPU 4/1 线程和生产预处理。
CPU/GPU 解码结果全部一致,无失败或超时。
| 平台/集显 | 5 次校准 GPU/CPU 比值范围 | 3 次独立验收比值范围 | 验收选择 |
|---|---:|---:|---|
| Ultra 5 225H / Arc 130T | 0.499185–0.550824 | 0.468736–0.536320 | GPU,3/3 |
| i7-12700 / UHD 770 | 0.841969–1.671434 | 1.436512–1.701212 | CPU,3/3 |
| Ryzen AI MAX+ PRO 395 / 8060S | 0.329776–0.401647 | 0.366498–0.393614 | GPU,3/3 |
比值为 GPU 两次正式推理累计时间 / CPU 两次正式推理累计时间;越小表示 GPU 越快。
会话加载、预热、解释器启动、文件读写和结果检查不进入这个比值。
校准交替执行顺序,验收按生产流程固定 CPU 先、GPU 后。
## 历史校准阈值计算
- L = Ultra 5 与 MAX395 全部有效比值的最大值 = `0.5508244759786391`。
- U = i7 最小有效比值与 1.0 的较小值 = `0.841969197269001`。
- T = `(L + U) / 2` = `0.69639683662382`。
i7 校准中有一次比值为 0.841969,其余为 1.54–1.67;保留全部记录,没有剔除这一测量。
三个后续独立验收均选择 CPU。阈值不依赖 CPU 名称或 GPU 型号匹配。
正常设备发现另已确认 i7 主机的首选为 RTX 3060 Ti、普通 DXGI 索引 1;
这台主机在应用正常 auto 模式下仍遵从独显优先,UHD 770 只由校准工具显式选择。
## 0.8 阈值:UHD 770 十次补验
用户将正式阈值简化为 0.8 后,在同一 i7-12700 / UHD 770 上补跑 10 个独立新进程。
每次均按生产顺序 CPU 先、GPU 后,使用相同六图、权重、预处理、DML 1.24.4、4/1 线程及 Intel rec 预设。
样本、输入和模型校验值与原三机报告完全一致。
**10/10 均选择 CPU,误选 GPU、超时和错误均为 0,CPU/GPU 解码结果全部一致。**
GPU/CPU 耗时比最小 **1.541682**、最大 **1.859539**、中位数 **1.605701**,均高于 0.8。
| 次数 | CPU 两次合计(ms) | GPU 两次合计(ms) | GPU/CPU | 选择 |
|---|---:|---:|---:|---|
| 1 | 316.777 | 498.098 | 1.572392 | CPU |
| 2 | 317.742 | 489.857 | 1.541682 | CPU |
| 3 | 317.435 | 493.345 | 1.554159 | CPU |
| 4 | 290.602 | 540.386 | 1.859539 | CPU |
| 5 | 311.247 | 493.205 | 1.584608 | CPU |
| 6 | 295.296 | 491.732 | 1.665219 | CPU |
| 7 | 293.490 | 490.314 | 1.670629 | CPU |
| 8 | 309.174 | 491.362 | 1.589269 | CPU |
| 9 | 293.326 | 490.909 | 1.673592 | CPU |
| 10 | 304.586 | 494.079 | 1.622133 | CPU |
原三机的 24 次历史记录按 0.8 重新判断也全部保持原选择:Ultra 5 / MAX395 为 GPU,i7/UHD 为 CPU。
本次阈值更新相关 41 项测试通过;之前 593 项实现回归和 19 页一致性对照保留为历史验证记录。
[本次完整逐次计时](benchmarks/onnx-selection-uhd770-threshold08-windows-20261018.json)。
原始补验报告 SHA256:`2f10d1a93488413a44fa2c4012ebca6ccfd513ef593ad6ee50cfd7decad98f63`。
## 延迟与复现
Ultra 5 的五次校准 worker 墙钟为 8.17–8.39 秒,MAX395 为 1.79–1.91 秒。
这包含加载和第一次运行,因此不将“约 1–2 秒小样本推理目标”描述为所有机器的首次总延迟。
完整的初始化、首次执行、正式计时及 worker 墙钟见机器可读报告。
三台使用相同的模型、manifest 和预处理输入校验值:
- 模型:`5435fd747c9e0efe15a96d0b378d5bd157e9492ed8fd80edf08f30d02fa24634`
- Manifest:`5d6728644b7f9ba220b3072eb00b6d436e774f32f3d30cddd06f0017042f4375`
- 输入:`474fa06cbc4c20303081e85084b45f3a4f24190d1141e4bd3bf3f3feb432c078`
- 输入 shape:`[6, 3, 48, 1012]`
正式开关为 Intel rec 关闭 metacommands、AMD rec 开启。实际 provider 均经过检查。
源码包和原始逐机报告保存在本轮 `output/onnx-selection-20261018/`,
可提交的完整逐次计时见 [机器可读报告](benchmarks/onnx-selection-windows-20261018.json)。
校准与重新执行方法见 [ONNX 设备选择](onnx-device.md)。
## 正式入口与结果一致性
初版实现相关回归共 **593 项通过**,覆盖设备策略、配置原子写入、会话并发、MFR 尾批、模型预加载、
CLI、PDF 模型流程及资源生命周期;另有两项第三方弃用提示。wheel 构建成功,确认包含六张 PNG、
manifest、正式阈值及互斥的平台运行时依赖。
本机使用正式解析入口,分别在新进程中配置 `onnx_device=cpu` 和 `gpu`,
对 demo1+demo2 合并的 19 页 PDF 执行 basic + 强制 OCR:
- 两次均完成全部 19 页,Middle JSON 的 `pages` 数组完全一致,包含页面顺序、块结构、边界框、正文与公式。
- Markdown 文件逐字节一致,SHA256 为 `6fa409c5986b5207a4cadff8d106a063c977180758babe615fc5ce58ebd86b49`。
- GPU 路线实际使用 DML 的组件为 layout、rec、MFR;检测、表格和 MFR 不足 8 张的尾批使用 CPU。
- 完整结果、会话 provider 和比较记录保存在本轮输出目录的 `document-*-verified/` 与 `document-comparison.json`。
额外使用隔离配置文件验证真实进程生命周期:首次 `auto` 测速后写入 `gpu`,下一新进程
返回 `configured_gpu`,未重新测速,配置中的其他键保持不变。
本机首次自动选择约 20.57 秒,包括 UMA 设备分类与约 8.11 秒的测速 worker;
这不是单独的 rec 推理耗时,也不包含随后生产模型的加载。
发现逐张创建 D3D12 设备分类会使后续选择仍耗时约 12 秒后,已优化保存为 `gpu` 的路径:
利用 Windows 高性能排序,并以空 `ppDevice` 检查 D3D12 能力,避免重复创建分类设备。
本机该枚举约 0.11 秒,包含 ORT 导入和读取配置的完整选择约 0.33 秒,所选 LUID/编号不变。
首次 `auto` 仍执行 UMA 分类;旧 Windows 缺少高性能排序接口时保留分类以确保独显优先。
接口行为参见 [D3D12CreateDevice](https://learn.microsoft.com/en-us/windows/win32/api/d3d12/nf-d3d12-d3d12createdevice)。
安装依赖声明已经切换到 Windows x64 的 `onnxruntime-directml>=1.23,<2`。
2026-10-18 已使用 `mineru-magika==1.0.3` 和 DocVortex 固定兼容提交完成 Windows x64
完整依赖验收,`uv pip check` 通过,两个共享模块各自只有一个发行包所有者。
DocVortex 的同版本 PyPI 元数据尚未更新;本次源码构建来源、回归与平台求解结果见
[依赖验收报告](onnx-dependency-validation.md)。
+19 -1
View File
@@ -28,6 +28,7 @@ MINERU_ENV_PREFIX = "MINERU_"
AutoBool = Literal["auto"] | bool
ConfigSource = Literal["default", "file", "env"]
ModelSource = Literal["auto", "huggingface", "modelscope", "local"]
OnnxDevice = Literal["auto", "cpu", "gpu"]
LogLevel = Literal["trace", "debug", "info", "warning", "error", "critical"]
_INTERPOLATION_RE = re.compile(r"\$\{(\w+)(?::-([^${}]*))?\}")
@@ -268,7 +269,8 @@ def get_config_file_exists() -> bool:
return _loaded_config.config_file_exists
def update_config_file(patch: dict[str, Any]) -> None:
def update_config_file(patch: dict[str, Any], *, only_if_onnx_auto: bool = False) -> bool:
"""Atomically merge a patch, optionally comparing the current ONNX choice under the lock."""
config_file = get_config_file_path()
config_dir = os.path.dirname(config_file)
if config_dir:
@@ -283,6 +285,11 @@ def update_config_file(patch: dict[str, Any]) -> None:
else:
current = {}
if only_if_onnx_auto:
model = current.get("model", {})
if not isinstance(model, dict) or model.get("onnx_device", "auto") != "auto":
return False
updated = _deep_merge(current, patch)
fd, tmp_path = tempfile.mkstemp(prefix=".config.", suffix=".tmp", dir=config_dir or None)
try:
@@ -292,6 +299,14 @@ def update_config_file(patch: dict[str, Any]) -> None:
finally:
if os.path.exists(tmp_path):
os.unlink(tmp_path)
return True
def read_onnx_device_from_file() -> OnnxDevice:
"""Re-read only the device choice after acquiring the cross-process selection lock."""
path = get_config_file_path()
raw = _load_config(path) if os.path.isfile(path) else {}
return ModelConfig(**raw.get("model", {})).onnx_device
class UDSConfig(BaseModel):
@@ -502,6 +517,7 @@ class ModelConfig(BaseModel):
base_dir: str = _default_path("models")
source: str = "auto"
small_backend: Literal["auto", "onnx", "torch"] = "auto"
onnx_device: OnnxDevice = "auto"
vlm: VlmConfig = Field(default_factory=VlmConfig)
@model_validator(mode="before")
@@ -575,6 +591,8 @@ def PatchedConfig(**kwargs: Any) -> Config:
__all__ = [
"OnnxDevice",
"read_onnx_device_from_file",
"AutoBool",
"ConfigSource",
"LoadedConfig",
+2
View File
@@ -148,6 +148,8 @@ def show_cmd(
f"model.source.source: {get_config_source('model.source')}",
f"model.small_backend: {config.model.small_backend}",
f"model.small_backend.source: {get_config_source('model.small_backend')}",
f"model.onnx_device: {config.model.onnx_device}",
f"model.onnx_device.source: {get_config_source('model.onnx_device')}",
f"model.vlm.engine: {config.model.vlm.engine}",
f"model.vlm.engine.source: {get_config_source('model.vlm.engine')}",
t("Effective small backend: {backend}", backend=effective_small_backend),
+1 -1
View File
@@ -66,7 +66,7 @@ class PPDocLayoutV2LayoutModelONNX(PPDocLayoutV2PostProcessor):
self.imgsz = tuple(resize["target_size"])
self.interpolation = int(resize["interp"])
self.rescale_factor = 1.0 / 255.0
self.session = ort_session(self.model_path, device, intra_op_num_threads)
self.session = ort_session(self.model_path, device, intra_op_num_threads, model_kind="layout")
self._input_names = [i.name for i in self.session.get_inputs()]
self._output_name = self.session.get_outputs()[0].name
logger.debug(
+14 -2
View File
@@ -13,6 +13,7 @@ ONNX 模型输出 token IDs(int64),不是 logits——自回归循环已 b
from __future__ import annotations
import math
import threading
from pathlib import Path
from typing import Any, List, Optional, Tuple
@@ -51,7 +52,12 @@ class PPFormulaNetPlusMONNX:
) -> None:
"""初始化 CPU 公式模型,并复用 Torch Plus-M 的纯 Python 处理器。"""
self.device = "cpu"
self.session = ort_session(model_path, device, intra_op_num_threads)
self.session = ort_session(model_path, device, intra_op_num_threads, model_kind="mfr")
self.cpu_tail_enabled = "DmlExecutionProvider" in self.session.get_providers()
self._cpu_tail_session = None
self._cpu_tail_lock = threading.Lock()
self._model_path = model_path
self._intra_op_num_threads = intra_op_num_threads
self.input_name = self.session.get_inputs()[0].name
# 从 yml 加载 tokenizer
@@ -177,7 +183,13 @@ class PPFormulaNetPlusMONNX:
step = min(CPU_BATCH_SIZE, max(1, batch_size))
for start in range(0, len(valid_inputs), step):
inputs = np.concatenate(valid_inputs[start : start + step], axis=0).astype(np.float32)
preds = self.session.run(None, {self.input_name: inputs})[0]
session = self.session
if self.cpu_tail_enabled and inputs.shape[0] < CPU_BATCH_SIZE:
with self._cpu_tail_lock:
if self._cpu_tail_session is None:
self._cpu_tail_session = ort_session(self._model_path, "cpu", self._intra_op_num_threads)
session = self._cpu_tail_session
preds = session.run(None, {self.input_name: inputs})[0]
if preds.ndim != 2 or preds.shape[0] != inputs.shape[0]:
raise ValueError("Formula ONNX output batch does not match input batch")
for offset, tokens in enumerate(preds):
+1 -1
View File
@@ -262,7 +262,7 @@ class TextRecognizerONNX:
intra_op_num_threads: int = 0,
) -> None:
"""加载 CPU ONNX 会话并设置推理参数。"""
self.session = ort_session(model_path, device, intra_op_num_threads)
self.session = ort_session(model_path, device, intra_op_num_threads, model_kind="ocr_rec")
self.input_name = self.session.get_inputs()[0].name
self.img_c, self.img_h, self.img_w = rec_image_shape
+17 -5
View File
@@ -104,7 +104,9 @@ def table_cls_model_init(small_backend: str | None = None) -> PaddleTableClsMode
"""初始化有线与无线表格类型分类模型。"""
from ..table.cls.paddle_table_cls import PaddleTableClsModel
return PaddleTableClsModel(model_path=str(small_model_repo(small_backend).paddle_table_cls.ensure()))
return PaddleTableClsModel(
model_path=str(small_model_repo(small_backend).paddle_table_cls.ensure()), small_backend=small_backend
)
def wired_table_model_init(
@@ -123,7 +125,9 @@ def wired_table_model_init(
lang=lang,
enable_merge_det_boxes=False,
)
table_model = UnetTableModel(ocr_engine, model_path=str(small_model_repo(small_backend).unet_structure.ensure()))
table_model = UnetTableModel(
ocr_engine, model_path=str(small_model_repo(small_backend).unet_structure.ensure()), small_backend=small_backend
)
return table_model
@@ -143,7 +147,9 @@ def wireless_table_model_init(
lang=lang,
enable_merge_det_boxes=False,
)
table_model = PaddleTableModel(ocr_engine, model_path=str(small_model_repo(small_backend).slanet_plus.ensure()))
table_model = PaddleTableModel(
ocr_engine, model_path=str(small_model_repo(small_backend).slanet_plus.ensure()), small_backend=small_backend
)
return table_model
@@ -233,6 +239,10 @@ class AtomModelSingleton:
key = atom_model_name
key = (small_backend, device, key)
if small_backend == "onnx":
from .onnx_device import resolve_onnx_device
key = (*key, resolve_onnx_device())
with self._lock:
if key not in self._models:
self._models[key] = atom_model_init(model_name=atom_model_name, **kwargs)
@@ -339,7 +349,7 @@ class HybridLocalModelContextSingleton:
"""全局缓存并复用 Hybrid 本地模型上下文。"""
_instance: HybridLocalModelContextSingleton | None = None
_models: dict[tuple[str, str], HybridLocalModelContext] = {}
_models: dict[tuple[object, ...], HybridLocalModelContext] = {}
_lock: threading.RLock = LOCAL_MODEL_INIT_LOCK
def __new__(cls, *args: Any, **kwargs: Any) -> HybridLocalModelContextSingleton:
@@ -355,7 +365,9 @@ class HybridLocalModelContextSingleton:
"""按小模型后端与实际设备缓存上下文,避免切换配置后混用模型。"""
small_backend = resolve_small_model_backend()
device = "cpu" if small_backend == "onnx" else get_device()
key = (small_backend, device)
from ...config import config
key = (small_backend, device, config.model.onnx_device if small_backend == "onnx" else None)
with self._lock:
if key not in self._models:
self._models[key] = HybridLocalModelContext(device=device, small_backend=small_backend)
+116 -6
View File
@@ -3,11 +3,43 @@
import os
import time
import threading
from pathlib import Path
from typing import Any
import onnxruntime as ort
from loguru import logger
from .onnx_device import OnnxSelection, confirm_gpu_session, metacommands_enabled, resolve_onnx_device
from .windows_gpu import GpuAdapter
class DirectMLSession:
"""Serialize Run on a DML session, independently of host inference-lock settings."""
def __init__(self, session: ort.InferenceSession) -> None:
self._session = session
self._lock = threading.Lock()
def run(self, output_names: list[str] | None, input_feed: dict[str, Any], run_options: Any = None) -> list[Any]:
with self._lock:
return self._session.run(output_names, input_feed, run_options)
def get_inputs(self) -> list[Any]:
return self._session.get_inputs()
def get_outputs(self) -> list[Any]:
return self._session.get_outputs()
def get_providers(self) -> list[str]:
return self._session.get_providers()
def get_provider_options(self) -> dict[str, dict[str, str]]:
return self._session.get_provider_options()
def end_profiling(self) -> str:
return self._session.end_profiling()
def get_op_num_threads(env_name: str) -> int:
"""读取 ONNX 算子线程数,缺失或非法时返回负一。"""
@@ -43,20 +75,90 @@ def ort_providers(device: str | None = None) -> list[tuple[str, dict[str, object
return [("CPUExecutionProvider", {"arena_extend_strategy": "kSameAsRequested"})]
def ort_session(model_path: str, device: str | None = None, intra_op_num_threads: int = 0) -> ort.InferenceSession:
"""创建 CPU 会话;显式线程数优先,其次环境配置,默认使用 4/1 线程。"""
def create_ort_session(
model_path: str,
*,
adapter: GpuAdapter | None = None,
model_kind: str = "",
intra_op_num_threads: int = 0,
inter_op_num_threads: int = 0,
profile_prefix: str | None = None,
) -> ort.InferenceSession | DirectMLSession:
"""Create an explicit CPU/DML session without selection, downloads or config writes."""
opts = ort.SessionOptions()
opts.log_severity_level = 3
opts.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
configure_ort_threads(opts, intra_op_num_threads=intra_op_num_threads)
return ort.InferenceSession(model_path, sess_options=opts, providers=ort_providers(device))
configure_ort_threads(opts, intra_op_num_threads=intra_op_num_threads, inter_op_num_threads=inter_op_num_threads)
providers = ort_providers()
if adapter is not None:
if "DmlExecutionProvider" not in ort.get_available_providers():
raise RuntimeError("DmlExecutionProvider is unavailable")
opts.enable_mem_pattern = False
opts.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL
options: dict[str, object] = {"device_id": adapter.index}
enabled = metacommands_enabled(adapter.vendor, model_kind)
if enabled is not None:
options["disable_metacommands"] = "false" if enabled else "true"
providers = [("DmlExecutionProvider", options), *providers]
if profile_prefix is not None:
opts.enable_profiling = True
opts.profile_file_prefix = profile_prefix
session = ort.InferenceSession(model_path, sess_options=opts, providers=providers)
if adapter is not None:
if session.get_providers()[0] != "DmlExecutionProvider":
raise RuntimeError("Requested DML session fell back to CPU during initialization")
session.disable_fallback()
return DirectMLSession(session)
return session
def table_ort_session(model_path: str, *, sess_options: ort.SessionOptions | None = None) -> ort.InferenceSession:
def ort_session(
model_path: str,
device: str | None = None,
intra_op_num_threads: int = 0,
*,
model_kind: str = "",
selection: OnnxSelection | None = None,
) -> ort.InferenceSession | DirectMLSession:
"""Use the process ONNX choice only for layout, rec and MFR; other models stay on CPU."""
eligible = model_kind in {"layout", "ocr_rec", "mfr"}
route = (selection or resolve_onnx_device(intra_op_num_threads=intra_op_num_threads)) if eligible else OnnxSelection()
adapter = route.adapter if route.device == "gpu" else None
started = time.perf_counter()
try:
session = create_ort_session(
model_path, adapter=adapter, model_kind=model_kind, intra_op_num_threads=intra_op_num_threads
)
except Exception as error:
if adapter is None:
raise
logger.warning("ONNX {} DML initialization failed; using CPU: {}", model_kind, error)
session = create_ort_session(model_path, intra_op_num_threads=intra_op_num_threads)
else:
if adapter is not None:
confirm_gpu_session(route)
logger.info(
"ONNX session ready: kind={}, providers={}, gpu={}, metacommands={}, init_s={:.3f}",
model_kind,
session.get_providers(),
adapter,
metacommands_enabled(adapter.vendor, model_kind) if adapter else None,
time.perf_counter() - started,
)
return session
def table_ort_session(
model_path: str, *, sess_options: ort.SessionOptions | None = None, small_backend: str | None = None
) -> ort.InferenceSession:
"""表格默认按可用 provider 选择设备;支持固定 CPU/CUDA,并明确记录 CUDA 失败回退。"""
device = os.getenv("MINERU_TABLE_DEVICE", "auto").strip().lower()
if device not in {"auto", "cpu", "cuda"}:
raise ValueError("MINERU_TABLE_DEVICE must be 'auto', 'cpu' or 'cuda'")
from .device import resolve_small_model_backend
if resolve_small_model_backend(small_backend) == "onnx":
device = "cpu"
opts = sess_options if sess_options is not None else ort.SessionOptions()
opts.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
opts.log_severity_level = 3
@@ -107,4 +209,12 @@ def table_ort_session(model_path: str, *, sess_options: ort.SessionOptions | Non
return session
__all__ = ["configure_ort_threads", "get_op_num_threads", "ort_providers", "ort_session", "table_ort_session"]
__all__ = [
"DirectMLSession",
"configure_ort_threads",
"create_ort_session",
"get_op_num_threads",
"ort_providers",
"ort_session",
"table_ort_session",
]
+185
View File
@@ -0,0 +1,185 @@
"""Bounded rec calibration worker, shared by automatic selection and the release benchmark."""
from __future__ import annotations
from dataclasses import asdict
import hashlib
import json
import math
import os
from pathlib import Path
import subprocess
import sys
import time
from typing import Any
from .windows_gpu import GpuAdapter
CALIBRATION_TIMEOUT_SECONDS = 15
def sample_directory() -> Path:
"""Locate installed package data, independently of the working directory."""
return Path(__file__).resolve().parents[2] / "resources/onnx_calibration"
def prepare_input() -> tuple[Any, dict[str, Any]]:
"""Check bundled PNG hashes and prepare the exact production batch once."""
import numpy as np
from PIL import Image
from ..ocr.image import resize_text_recognition_image
root = sample_directory()
manifest_bytes = (root / "manifest.json").read_bytes()
manifest = json.loads(manifest_bytes)
samples = manifest["samples"]
if len(samples) != 6:
raise ValueError("Calibration requires exactly six samples")
images = []
for sample in samples:
path = root / sample["file"]
if path.parent != root or hashlib.sha256(path.read_bytes()).hexdigest() != sample["sha256"]:
raise ValueError("Calibration sample checksum mismatch")
with Image.open(path) as image:
images.append(np.ascontiguousarray(np.asarray(image.convert("RGB"))[:, :, ::-1]))
ratios = np.array([image.shape[1] / image.shape[0] for image in images])
order = np.argsort(ratios)
tensor = np.concatenate(
[resize_text_recognition_image(images[index], float(ratios.max()), (3, 48, 320))[None] for index in order]
).astype(np.float32)
return tensor, {
"manifest_sha256": hashlib.sha256(manifest_bytes).hexdigest(),
"input_sha256": hashlib.sha256(tensor.tobytes()).hexdigest(),
"shape": list(tensor.shape),
"sample_order": order.tolist(),
}
def _tokens(outputs: Any) -> list[list[int]]:
import numpy as np
if outputs.ndim != 3 or outputs.shape[0] != 6 or not np.isfinite(outputs).all():
raise ValueError("Invalid rec calibration output")
result = []
for row in outputs.argmax(axis=-1):
keep = np.ones(len(row), dtype=bool)
keep[1:] = row[1:] != row[:-1]
keep &= row != 0
result.append(row[keep].tolist())
return result
def measure_rec(
model_path: str,
adapter: GpuAdapter,
*,
intra_op_num_threads: int = 0,
inter_op_num_threads: int = 0,
gpu_first: bool = False,
) -> dict[str, Any]:
"""One warm-up and two timed runs per device; never consult the automatic selector."""
import onnxruntime as ort
from .onnx import configure_ort_threads, create_ort_session
from .onnx_device import metacommands_enabled
tensor, evidence = prepare_input()
options = ort.SessionOptions()
configure_ort_threads(options, intra_op_num_threads=intra_op_num_threads, inter_op_num_threads=inter_op_num_threads)
result: dict[str, Any] = {
"schema_version": 1,
"adapter": asdict(adapter),
"input": evidence,
"model_sha256": hashlib.sha256(Path(model_path).read_bytes()).hexdigest(),
"ort_version": ort.__version__,
"ort_path": ort.__file__,
"python": sys.version,
"intra_threads": options.intra_op_num_threads,
"inter_threads": options.inter_op_num_threads,
"rec_metacommands": metacommands_enabled(adapter.vendor, "ocr_rec"),
"gpu_first": gpu_first,
}
decoded: dict[str, list[list[int]]] = {}
for device in ("gpu", "cpu") if gpu_first else ("cpu", "gpu"):
started = time.perf_counter()
session = create_ort_session(
model_path,
adapter=adapter if device == "gpu" else None,
model_kind="ocr_rec",
intra_op_num_threads=options.intra_op_num_threads,
inter_op_num_threads=options.inter_op_num_threads,
)
init_s = time.perf_counter() - started
name = session.get_inputs()[0].name
elapsed: list[float] = []
reference = None
for _ in range(3):
started = time.perf_counter()
outputs = session.run(None, {name: tensor})[0]
elapsed.append(time.perf_counter() - started)
tokens = _tokens(outputs)
if reference is not None and tokens != reference:
raise ValueError(f"{device} rec calibration output changed between runs")
reference = tokens
decoded[device] = reference
result[device] = {"init_s": init_s, "first_s": elapsed[0], "timed_s": elapsed[1:], "providers": session.get_providers()}
result[f"{device}_elapsed"] = sum(elapsed[1:])
del session
if decoded["cpu"] != decoded["gpu"]:
raise ValueError("CPU and GPU rec calibration text differ")
result["ratio"] = result["gpu_elapsed"] / result["cpu_elapsed"]
result["outputs_match"] = True
return result
def run_calibration(
model_path: str,
adapter: GpuAdapter,
*,
intra_op_num_threads: int = 0,
inter_op_num_threads: int = 0,
gpu_first: bool = False,
) -> dict[str, Any]:
"""Run a fresh, hidden worker and kill it on timeout; failed measurements are never decisions."""
request = {
"model_path": model_path,
"adapter": asdict(adapter),
"intra_op_num_threads": intra_op_num_threads,
"inter_op_num_threads": inter_op_num_threads,
"gpu_first": gpu_first,
}
environment = {**os.environ, "PYTHONUTF8": "1", "MINERU_MODEL_ONNX_DEVICE": "cpu"}
started = time.perf_counter()
process = subprocess.run(
[sys.executable, "-B", "-m", "mineru.model.runtime.onnx_calibration"],
input=json.dumps(request),
capture_output=True,
text=True,
encoding="utf-8",
env=environment,
timeout=CALIBRATION_TIMEOUT_SECONDS,
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0,
)
if process.returncode:
raise RuntimeError(f"ONNX calibration worker failed ({process.returncode}): {process.stderr[-1500:]}")
result = json.loads(process.stdout)
for key in ("cpu_elapsed", "gpu_elapsed"):
if not isinstance(result.get(key), (float, int)) or not math.isfinite(result[key]) or result[key] <= 0:
raise ValueError("Calibration worker returned invalid timings")
if result.get("outputs_match") is not True or result["gpu"]["providers"][0] != "DmlExecutionProvider":
raise ValueError("Calibration worker did not confirm valid DirectML results")
result["worker_wall_s"] = time.perf_counter() - started
return result
def main() -> None:
"""Private JSON-over-stdin worker; no user files or global configuration are modified."""
request = json.load(sys.stdin)
adapter = GpuAdapter(**request.pop("adapter"))
result = measure_rec(adapter=adapter, **request)
print(json.dumps(result, ensure_ascii=True))
__all__ = ["CALIBRATION_TIMEOUT_SECONDS", "measure_rec", "prepare_input", "run_calibration", "sample_directory"]
if __name__ == "__main__":
main()
+156
View File
@@ -0,0 +1,156 @@
"""Process-local ONNX selection, with a single persisted cpu/gpu preference."""
from __future__ import annotations
from dataclasses import dataclass
import os
from pathlib import Path
import sys
import threading
from typing import Literal
from filelock import FileLock
from loguru import logger
from .windows_gpu import GpuAdapter, discover_gpus, preferred_gpu
# Require at least 20% lower GPU inference time; simplified to 0.8 at the user's request.
# Evidence: docs/next/onnx-selection-validation.md. None disables unvalidated auto-iGPU choices.
ONNX_GPU_TIME_RATIO_THRESHOLD: float | None = 0.8
METACOMMANDS: dict[str, dict[str, bool]] = {
"intel": {"layout": False, "mfr": True, "ocr_rec": False},
"amd": {"layout": True, "mfr": True, "ocr_rec": True},
"nvidia": {"layout": True, "mfr": True, "ocr_rec": False},
}
@dataclass(frozen=True)
class OnnxSelection:
"""Immutable actual route. Adapter identity is valid only in this process."""
device: Literal["cpu", "gpu"] = "cpu"
adapter: GpuAdapter | None = None
reason: str = "cpu"
_lock = threading.RLock()
_selections: dict[tuple[int, str, str, str], OnnxSelection] = {}
def metacommands_enabled(vendor: str, model_kind: str) -> bool | None:
"""None leaves an unknown vendor on the ORT default."""
return METACOMMANDS.get(vendor, {}).get(model_kind)
def choose_gpu(cpu_elapsed: float, gpu_elapsed: float, threshold: float) -> bool:
"""Compare valid elapsed times with the calibrated, conservatively bounded threshold."""
import math
if not all(math.isfinite(value) and value > 0 for value in (cpu_elapsed, gpu_elapsed, threshold)):
raise ValueError("Calibration times and threshold must be finite and positive")
if threshold >= 1:
raise ValueError("GPU threshold must be below 1")
return gpu_elapsed / cpu_elapsed <= threshold
def _select(requested: str, intra_op_num_threads: int) -> tuple[OnnxSelection, bool]:
if requested == "cpu":
return OnnxSelection(reason="configured_cpu"), False
if sys.platform != "win32":
if requested == "gpu":
logger.warning("ONNX GPU selection requires Windows DirectML; using CPU")
return OnnxSelection(reason="unsupported_platform"), False
import onnxruntime as ort
if "DmlExecutionProvider" not in ort.get_available_providers():
logger.warning("DirectML provider unavailable; using CPU without saving a performance decision")
return OnnxSelection(reason="directml_unavailable"), False
adapter = preferred_gpu(discover_gpus(classify=requested == "auto"))
if adapter is None:
return OnnxSelection(reason="no_hardware_gpu"), requested == "auto"
if requested == "gpu" or adapter.uma is False:
return OnnxSelection("gpu", adapter, "configured_gpu" if requested == "gpu" else "discrete_gpu"), False
if ONNX_GPU_TIME_RATIO_THRESHOLD is None:
logger.warning("ONNX integrated-GPU threshold has not completed calibration; using CPU without saving")
return OnnxSelection(reason="calibration_pending"), False
from ..registry import MINERU_4_MODELS_ONNX
from .onnx_calibration import run_calibration
# Download follows the normal model lifecycle; it is outside the 15-second worker timeout.
model = MINERU_4_MODELS_ONNX.ocr_rec.ensure()
result = run_calibration(str(model), adapter, intra_op_num_threads=intra_op_num_threads)
use_gpu = choose_gpu(result["cpu_elapsed"], result["gpu_elapsed"], ONNX_GPU_TIME_RATIO_THRESHOLD)
logger.bind(onnx_calibration=result).info("ONNX rec calibration: {}", result)
return OnnxSelection("gpu" if use_gpu else "cpu", adapter if use_gpu else None, "rec_calibration"), True
def _select_and_save(requested: str, source: str, intra_op_num_threads: int) -> OnnxSelection:
from ...config import read_onnx_device_from_file, update_config_file
if requested == "auto" and source != "env":
requested = read_onnx_device_from_file()
selection, completed = _select(requested, intra_op_num_threads)
if completed and requested == "auto" and source != "env":
try:
update_config_file({"model": {"onnx_device": selection.device}}, only_if_onnx_auto=True)
except OSError as error:
logger.warning("ONNX choice is process-local because config could not be saved: {}", error)
return selection
def resolve_onnx_device(*, intra_op_num_threads: int = 0) -> OnnxSelection:
"""Resolve at first model use; imports and diagnostic commands never call this function."""
from ...config import config, get_config_file_path, get_config_source
requested = config.model.onnx_device
source = get_config_source("model.onnx_device")
path = get_config_file_path()
key = (os.getpid(), path, requested, source)
with _lock:
if key in _selections:
return _selections[key]
try:
if requested != "auto" or sys.platform != "win32":
selection = _select_and_save(requested, source, intra_op_num_threads)
else:
try:
Path(path).parent.mkdir(parents=True, exist_ok=True)
selection_lock = FileLock(f"{path}.onnx-selection.lock", timeout=30)
selection_lock.acquire()
except OSError:
selection = _select_and_save(requested, source, intra_op_num_threads)
else:
try:
selection = _select_and_save(requested, source, intra_op_num_threads)
finally:
selection_lock.release()
except Exception as error:
logger.warning("ONNX device selection failed; using CPU for this process: {}", error)
selection = OnnxSelection(reason="selection_failed")
_selections[key] = selection
logger.info("ONNX device selected: {}", selection)
return selection
def confirm_gpu_session(selection: OnnxSelection) -> None:
"""Persist a discrete-GPU auto choice only after an actual DML session succeeded."""
if selection.reason != "discrete_gpu":
return
from ...config import config, get_config_source, update_config_file
if config.model.onnx_device == "auto" and get_config_source("model.onnx_device") != "env":
try:
update_config_file({"model": {"onnx_device": "gpu"}}, only_if_onnx_auto=True)
except OSError as error:
logger.warning("GPU is usable but config could not be saved: {}", error)
__all__ = [
"METACOMMANDS",
"ONNX_GPU_TIME_RATIO_THRESHOLD",
"OnnxSelection",
"choose_gpu",
"confirm_gpu_session",
"metacommands_enabled",
"resolve_onnx_device",
]
+175
View File
@@ -0,0 +1,175 @@
"""Lazy Windows GPU discovery; DXGI indices are never persisted."""
from __future__ import annotations
import ctypes as C
from dataclasses import dataclass
import sys
from typing import Any
import uuid
@dataclass(frozen=True)
class GpuAdapter:
"""One hardware adapter, with its ordinary DXGI index and preference rank."""
index: int
luid: str
name: str
vendor_id: int
device_id: int
uma: bool | None
preference: int
@property
def vendor(self) -> str:
return {0x8086: "intel", 0x1002: "amd", 0x10DE: "nvidia"}.get(self.vendor_id, "unknown")
class _Guid(C.Structure):
_fields_ = [("a", C.c_uint32), ("b", C.c_uint16), ("c", C.c_uint16), ("d", C.c_uint8 * 8)]
class _Luid(C.Structure):
_fields_ = [("low", C.c_uint32), ("high", C.c_int32)]
class _Description(C.Structure):
_fields_ = [
("name", C.c_wchar * 128),
("vendor", C.c_uint32),
("device", C.c_uint32),
("subsystem", C.c_uint32),
("revision", C.c_uint32),
("video_memory", C.c_size_t),
("system_memory", C.c_size_t),
("shared_memory", C.c_size_t),
("luid", _Luid),
("flags", C.c_uint32),
]
class _Architecture(C.Structure):
_fields_ = [
("node", C.c_uint32),
("tile_based", C.c_int32),
("uma", C.c_int32),
("coherent", C.c_int32),
("isolated", C.c_int32),
]
def _guid(value: str) -> _Guid:
return _Guid.from_buffer_copy(uuid.UUID(value).bytes_le)
def _method(pointer: C.c_void_p, slot: int, result: Any, *args: Any) -> Any:
vtable = C.cast(pointer, C.POINTER(C.POINTER(C.c_void_p))).contents
return C.WINFUNCTYPE(result, C.c_void_p, *args)(vtable[slot])
def _release(pointer: C.c_void_p) -> None:
if pointer:
_method(pointer, 2, C.c_uint32)(pointer)
def _check(hr: int) -> None:
if hr < 0:
raise OSError(f"Windows GPU discovery failed: HRESULT=0x{hr & 0xFFFFFFFF:08x}")
def _description(adapter: C.c_void_p) -> _Description:
desc = _Description()
_check(_method(adapter, 10, C.c_int32, C.POINTER(_Description))(adapter, C.byref(desc)))
return desc
def _luid(desc: _Description) -> str:
return f"{desc.luid.high & 0xFFFFFFFF:08x}:{desc.luid.low:08x}"
def _architecture(adapter: C.c_void_p, create: Any, *, classify: bool = True) -> tuple[bool, bool | None]:
device = C.c_void_p()
iid = _guid("189819f1-1db6-4b57-be54-1821339b85f7")
if not classify:
# A null ppDevice checks D3D12 capability without creating a device (success is S_FALSE).
return create(adapter, 0xB000, C.byref(iid), None) >= 0, None
hr = create(adapter, 0xB000, C.byref(iid), C.byref(device))
if hr < 0:
return False, None
try:
info = _Architecture()
check = _method(device, 13, C.c_int32, C.c_int32, C.c_void_p, C.c_uint32)
# D3D12_FEATURE_ARCHITECTURE1, falling back to ARCHITECTURE on older drivers.
hr = check(device, 16, C.byref(info), C.sizeof(info))
if hr < 0:
hr = check(device, 1, C.byref(info), C.sizeof(info) - C.sizeof(C.c_int32))
return True, bool(info.uma) if hr >= 0 else None
finally:
_release(device)
def discover_gpus(*, classify: bool = True) -> tuple[GpuAdapter, ...]:
"""Discover D3D12 hardware; configured GPU mode only needs capability and DXGI performance order."""
if sys.platform != "win32":
return ()
dxgi = C.WinDLL("dxgi", use_last_error=True)
d3d = C.WinDLL("d3d12", use_last_error=True)
create = dxgi.CreateDXGIFactory1
create.argtypes = [C.POINTER(_Guid), C.POINTER(C.c_void_p)]
create.restype = C.c_int32
create_device = d3d.D3D12CreateDevice
create_device.argtypes = [C.c_void_p, C.c_int32, C.POINTER(_Guid), C.POINTER(C.c_void_p)]
create_device.restype = C.c_int32
factory = C.c_void_p()
modern = True
iid = _guid("c1b6694f-ff09-44a9-b03c-77900a0a1d17")
if create(C.byref(iid), C.byref(factory)) < 0:
modern = False
iid = _guid("770aae78-f26f-4dba-a829-253c83d1b387")
_check(create(C.byref(iid), C.byref(factory)))
try:
ranks: dict[str, int] = {}
if modern:
enum_preference = _method(factory, 29, C.c_int32, C.c_uint32, C.c_int32, C.POINTER(_Guid), C.POINTER(C.c_void_p))
adapter_iid = _guid("29038f61-3839-4626-91fd-086879011a05")
for rank in range(64):
adapter = C.c_void_p()
hr = enum_preference(factory, rank, 2, C.byref(adapter_iid), C.byref(adapter))
if hr & 0xFFFFFFFF == 0x887A0002:
break
_check(hr)
try:
ranks[_luid(_description(adapter))] = rank
finally:
_release(adapter)
rows: list[GpuAdapter] = []
enum = _method(factory, 12, C.c_int32, C.c_uint32, C.POINTER(C.c_void_p))
for index in range(64):
adapter = C.c_void_p()
hr = enum(factory, index, C.byref(adapter))
if hr & 0xFFFFFFFF == 0x887A0002:
break
_check(hr)
try:
desc = _description(adapter)
if desc.flags & 2:
continue
# Old factories lack performance ordering, so classify to retain discrete-GPU priority.
supported, uma = _architecture(adapter, create_device, classify=classify or not modern)
if supported:
luid = _luid(desc)
rows.append(GpuAdapter(index, luid, desc.name, desc.vendor, desc.device, uma, ranks.get(luid, index)))
finally:
_release(adapter)
return tuple(sorted(rows, key=lambda item: (item.uma is not False, item.preference, item.index)))
finally:
_release(factory)
def preferred_gpu(adapters: tuple[GpuAdapter, ...]) -> GpuAdapter | None:
"""Prefer a confirmed discrete GPU, then Windows performance preference."""
return min(adapters, key=lambda item: (item.uma is not False, item.preference, item.index), default=None)
__all__ = ["GpuAdapter", "discover_gpus", "preferred_gpu"]
+4 -2
View File
@@ -10,11 +10,13 @@ from ...runtime.onnx import table_ort_session
class PaddleTableClsModel:
def __init__(self, *, model_path: str | None = None) -> None:
def __init__(self, *, model_path: str | None = None, small_backend: str | None = None) -> None:
"""加载所选模型栈的表格分类文件,使用表格专用设备策略。"""
from ...runtime.contracts import AtomicModelName
self.sess = table_ort_session(model_path or str(small_model_repo().paddle_table_cls.ensure()))
self.sess = table_ort_session(
model_path or str(small_model_repo(small_backend).paddle_table_cls.ensure()), small_backend=small_backend
)
self.less_length = 256
self.cw, self.ch = 224, 224
self.std = [0.229, 0.224, 0.225]
+4 -3
View File
@@ -19,6 +19,7 @@ from .table_structure import TableStructurer
@dataclass
class PaddleTableInput:
small_backend: str | None = None
model_type: Optional[str] = "slanet_plus"
model_path: Union[str, Path, None, Dict[str, str]] = None
use_cuda: bool = False
@@ -150,10 +151,10 @@ def escape_html(input_string):
class PaddleTableModel(object):
def __init__(self, ocr_engine: Any, *, model_path: str | None = None) -> None:
def __init__(self, ocr_engine: Any, *, model_path: str | None = None, small_backend: str | None = None) -> None:
"""加载所选模型栈的无线表格资源并共享其 OCR 引擎。"""
slanet_plus_model_path = model_path or str(small_model_repo().slanet_plus.ensure())
input_args = PaddleTableInput(model_type="slanet_plus", model_path=slanet_plus_model_path)
slanet_plus_model_path = model_path or str(small_model_repo(small_backend).slanet_plus.ensure())
input_args = PaddleTableInput(model_type="slanet_plus", model_path=slanet_plus_model_path, small_backend=small_backend)
self.table_model = PaddleTable(input_args)
self.ocr_engine = ocr_engine
@@ -35,6 +35,7 @@ class OrtInferSession:
self.session = table_ort_session(
model_path,
sess_options=self._init_sess_opts(config),
small_backend=config.get("small_backend"),
)
@staticmethod
+4 -3
View File
@@ -44,6 +44,7 @@ BLANK_CELL_REC_DROP_TEXTS = {
@dataclass
class WiredTableInput:
model_path: str
small_backend: str | None = None
device: str = "cpu"
@@ -261,10 +262,10 @@ def count_table_cells_physical(html_code):
class UnetTableModel:
def __init__(self, ocr_engine: Any, *, model_path: str | None = None) -> None:
def __init__(self, ocr_engine: Any, *, model_path: str | None = None, small_backend: str | None = None) -> None:
"""加载所选模型栈的有线表格资源并共享其 OCR 引擎。"""
model_path = model_path or str(small_model_repo().unet_structure.ensure())
wired_input_args = WiredTableInput(model_path=model_path)
model_path = model_path or str(small_model_repo(small_backend).unet_structure.ensure())
wired_input_args = WiredTableInput(model_path=model_path, small_backend=small_backend)
self.wired_table_model = WiredTableRecognition(wired_input_args, ocr_engine)
self.ocr_engine = ocr_engine
@@ -26,6 +26,7 @@ class OrtInferSession:
self.session = table_ort_session(
model_path,
sess_options=self._init_sess_opts(config),
small_backend=config.get("small_backend"),
)
@staticmethod
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

@@ -0,0 +1,49 @@
{
"version": 1,
"source": "Original MinerU calibration sentences, rendered by scripts/make_onnx_calibration_samples.py",
"font": "msyh.ttc",
"font_sha256": "d79c55e68b1131eea0cc1c47be4f572d964f28c682e143db2ad09c1e4cb07a3f",
"font_size": 32,
"preprocessing": {
"color": "BGR",
"image_shape": [
3,
48,
320
],
"batch": 6,
"normalization": "production rec"
},
"samples": [
{
"file": "line-1.png",
"text": "文档解析与智能识别 2026",
"sha256": "1e88f7e0a666403730db4bf96504ec07cde338c9633e71df407a1d65656613a0"
},
{
"file": "line-2.png",
"text": "文字、公式和表格转换为结构化内容,支持检索与分析。",
"sha256": "2be97b59d7f7d034a9655622961f91b1686da8a6e2686ce2c034e3479231f7a6"
},
{
"file": "line-3.png",
"text": "Document recognition and structured content",
"sha256": "c43bb9868537fa3e3b58bed9e30bfad0ba9405ad16773b6be7c2f60ce0f3d1a2"
},
{
"file": "line-4.png",
"text": "The quick brown fox jumps over the lazy dog. Page 123 of 456.",
"sha256": "0100ddf30393caf6bebca3bb0d574ec09165952ca0352f4d429631b13cdb5b17"
},
{
"file": "line-5.png",
"text": "0123456789 9876543210 2026-10-18",
"sha256": "ae5f25e5185ed9e1c3a8c757325fd01613393685def2ff31fe0559fa1d458689"
},
{
"file": "line-6.png",
"text": "Invoice 12345678: 3 x 128.50 = 385.50; Total 1,234.56",
"sha256": "87f4e84ee377bffdd330d8da092446eb9aa57763b047746fa820f39e03c7d83e"
}
]
}
+2 -1
View File
@@ -72,7 +72,8 @@ dependencies = [
"typer>=0.9.0",
"rich>=13.0.0",
"jieba>=0.42.1",
"onnxruntime>=1.20.1",
"onnxruntime-directml>=1.23,<2 ; sys_platform == 'win32' and (platform_machine == 'AMD64' or platform_machine == 'amd64' or platform_machine == 'x86_64')",
"onnxruntime>=1.20.1 ; sys_platform != 'win32' or (platform_machine != 'AMD64' and platform_machine != 'amd64' and platform_machine != 'x86_64')",
"ftfy>=6.3.1,<7",
"shapely>=2.0.7,<3",
"pyclipper>=1.3.0,<2",
+185
View File
@@ -0,0 +1,185 @@
"""Calibrate and independently validate the production six-line rec selection protocol."""
from __future__ import annotations
import argparse
from dataclasses import asdict
import json
from pathlib import Path
import platform
import subprocess
import sys
from typing import Any
from mineru.model.runtime.onnx_calibration import run_calibration
from mineru.model.runtime.onnx_device import choose_gpu
from mineru.model.runtime.windows_gpu import discover_gpus, preferred_gpu
def write_json(path: Path, data: dict[str, Any]) -> None:
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def environment() -> dict[str, Any]:
"""Record host and power evidence outside the timed worker."""
data: dict[str, Any] = {"host": platform.node(), "os": platform.platform(), "cpu": platform.processor()}
if sys.platform == "win32":
import winreg
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r"HARDWARE\DESCRIPTION\System\CentralProcessor\0") as key:
data["cpu"] = winreg.QueryValueEx(key, "ProcessorNameString")[0].strip()
power = subprocess.run(
["powercfg", "/getactivescheme"], capture_output=True, timeout=10, creationflags=subprocess.CREATE_NO_WINDOW
)
data["power_scheme"] = power.stdout.decode("utf-8", errors="replace").strip()
drivers = []
with winreg.OpenKey(
winreg.HKEY_LOCAL_MACHINE, r"SYSTEM\CurrentControlSet\Control\Class\{4d36e968-e325-11ce-bfc1-08002be10318}"
) as parent:
for i in range(winreg.QueryInfoKey(parent)[0]):
name = winreg.EnumKey(parent, i)
if not name.isdigit():
continue
with winreg.OpenKey(parent, name) as key:
try:
drivers.append(
{
field: winreg.QueryValueEx(key, field)[0]
for field in ("DriverDesc", "DriverVersion", "MatchingDeviceId")
}
)
except OSError:
continue
data["display_drivers"] = drivers
return data
def derive_threshold(reports: list[dict[str, Any]]) -> dict[str, Any]:
"""Choose the midpoint of the separating interval; reject mismatched or failed protocols."""
by_label = {report["label"]: report for report in reports}
if len(reports) != 3 or set(by_label) != {"ultra5", "i7", "max395"}:
raise ValueError("Exactly one ultra5, i7 and max395 report is required")
signatures = set()
ratios: dict[str, list[float]] = {}
for label, report in by_label.items():
if report.get("errors") or len(report["runs"]) != 5 or report.get("threshold") is not None:
raise ValueError(f"{label} needs five successful calibration processes")
ratios[label] = []
for run in report["runs"]:
if not run.get("outputs_match") or run["gpu"]["providers"][0] != "DmlExecutionProvider":
raise ValueError("Missing DirectML execution evidence")
signatures.add(
(
run["input"]["manifest_sha256"],
run["input"]["input_sha256"],
run["model_sha256"],
run["ort_version"],
run["intra_threads"],
run["inter_threads"],
)
)
ratio = run["gpu_elapsed"] / run["cpu_elapsed"]
choose_gpu(run["cpu_elapsed"], run["gpu_elapsed"], 0.5) # Validate finite positive timings only.
ratios[label].append(ratio)
if len(signatures) != 1:
raise ValueError("Samples, weights, ORT or thread settings differ across calibration runs")
lower = max(*ratios["ultra5"], *ratios["max395"])
upper = min(*ratios["i7"], 1.0)
if lower >= upper:
raise ValueError(f"No separating interval: L={lower}, U={upper}; revise samples and rerun")
return {
"threshold": (lower + upper) / 2,
"lower": lower,
"upper": upper,
"ratios": ratios,
"signature": list(next(iter(signatures))),
"status": "awaiting_independent_validation",
}
def run(args: argparse.Namespace) -> None:
adapters = discover_gpus()
if args.luid:
adapter = next((gpu for gpu in adapters if gpu.luid == args.luid), None)
else:
adapter = preferred_gpu(adapters)
if adapter is None:
raise ValueError("Requested hardware GPU was not found")
if adapter.uma is not True:
raise ValueError("Threshold calibration must explicitly target a confirmed integrated GPU")
if args.model is None:
from mineru.model.registry import MINERU_4_MODELS_ONNX
model = MINERU_4_MODELS_ONNX.ocr_rec.ensure()
else:
model = args.model.resolve(strict=True)
# Refuse to overwrite a completed or partial experiment.
args.output.mkdir(parents=True, exist_ok=False)
report: dict[str, Any] = {
"label": args.label,
"environment": environment(),
"adapter": asdict(adapter),
"threshold": args.threshold,
"runs": [],
"errors": [],
}
target = args.output / "report.json"
count = 5 if args.threshold is None else 3
for index in range(count):
try:
result = run_calibration(
str(model),
adapter,
intra_op_num_threads=4,
inter_op_num_threads=1,
gpu_first=args.threshold is None and index % 2 == 1,
)
if args.threshold is not None:
result["selected"] = (
"gpu" if choose_gpu(result["cpu_elapsed"], result["gpu_elapsed"], args.threshold) else "cpu"
)
report["runs"].append(result)
print(
f"{args.label} {index + 1}/{count}: ratio={result['ratio']:.6f}, wall={result['worker_wall_s']:.3f}s",
flush=True,
)
except Exception as error:
report["errors"].append({"process": index, "error": str(error)})
print(f"{args.label} {index + 1}/{count}: {error}", flush=True)
write_json(target, report)
if args.threshold is not None:
expected = "cpu" if args.label == "i7" else "gpu"
report["validation_passed"] = not report["errors"] and all(row["selected"] == expected for row in report["runs"])
write_json(target, report)
if report["errors"] or report.get("validation_passed") is False:
raise SystemExit(1)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
commands = parser.add_subparsers(dest="command", required=True)
commands.add_parser("devices")
measure = commands.add_parser("run")
measure.add_argument("--label", choices=("ultra5", "i7", "max395"), required=True)
measure.add_argument("--luid", help="Benchmark-only adapter identity; use devices to find the iGPU")
measure.add_argument("--model", type=Path)
measure.add_argument("--output", type=Path, required=True)
measure.add_argument("--threshold", type=float, help="Run three independent validation processes")
derive = commands.add_parser("derive")
derive.add_argument("reports", type=Path, nargs=3)
derive.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
if args.command == "devices":
print(json.dumps([asdict(gpu) for gpu in discover_gpus()], ensure_ascii=False, indent=2))
elif args.command == "run":
run(args)
else:
if args.output.exists():
raise FileExistsError(args.output)
result = derive_threshold([json.loads(path.read_text(encoding="utf-8")) for path in args.reports])
write_json(args.output, result)
print(json.dumps(result, indent=2))
if __name__ == "__main__":
main()
+14 -2
View File
@@ -66,7 +66,13 @@ def verify_wheels(resolution: str, platform: str, python: str, cache: Path, *, c
with urllib.request.urlopen(f"https://pypi.org/pypi/{name}/{version}/json", timeout=30) as response:
temporary = cached.with_suffix(f".{os.getpid()}.tmp")
temporary.write_bytes(response.read())
temporary.replace(cached)
try:
temporary.replace(cached)
except PermissionError:
# Another checker may have published the cache and opened it for reading on Windows.
if not cached.is_file():
raise
temporary.unlink()
data = json.loads(cached.read_text())
requires_python = data["info"].get("requires_python")
if requires_python and python not in SpecifierSet(requires_python):
@@ -85,7 +91,13 @@ def check_backend_dependencies(selected: set[str], package: str, extra: str, pla
checks = []
if package == "mineru":
# lxml 由 DocVortex 管理,不属于 MinerU 平台后端依赖。
required = {"gradio", "onnxruntime", "mineru-llama-cpp"}
runtime = "onnxruntime-directml" if platform == "windows" else "onnxruntime"
required = {"gradio", runtime, "mineru-llama-cpp", "mineru-magika"}
other_runtime = "onnxruntime" if platform == "windows" else "onnxruntime-directml"
if other_runtime in selected:
checks.append(f"conflicting {other_runtime} dependency on {platform}")
if "magika" in selected:
checks.append("upstream magika conflicts with the mineru-magika module namespace")
if platform == "macos" or extra in {"torch", "full", "all"}:
required.update({"torch", "torchvision", "transformers", "accelerate", "safetensors"})
if extra in {"full", "all"} and platform in {"linux", "windows"}:
+50
View File
@@ -0,0 +1,50 @@
"""Build the six bundled, original text-line fixtures; no font is bundled at runtime."""
from __future__ import annotations
import argparse
import hashlib
import json
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
TEXTS = (
"文档解析与智能识别 2026",
"文字、公式和表格转换为结构化内容,支持检索与分析。",
"Document recognition and structured content",
"The quick brown fox jumps over the lazy dog. Page 123 of 456.",
"0123456789 9876543210 2026-10-18",
"Invoice 12345678: 3 x 128.50 = 385.50; Total 1,234.56",
)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--font", type=Path, required=True)
args = parser.parse_args()
destination = Path(__file__).resolve().parents[1] / "mineru/resources/onnx_calibration"
destination.mkdir(parents=True, exist_ok=True)
font = ImageFont.truetype(str(args.font), 32)
rows = []
for index, text in enumerate(TEXTS):
bbox = font.getbbox(text)
image = Image.new("RGB", (bbox[2] - bbox[0] + 12, bbox[3] - bbox[1] + 12), "white")
ImageDraw.Draw(image).text((6 - bbox[0], 6 - bbox[1]), text, font=font, fill="black")
path = destination / f"line-{index + 1}.png"
image.save(path, optimize=True)
rows.append({"file": path.name, "text": text, "sha256": hashlib.sha256(path.read_bytes()).hexdigest()})
manifest = {
"version": 1,
"source": "Original MinerU calibration sentences, rendered by scripts/make_onnx_calibration_samples.py",
"font": args.font.name,
"font_sha256": hashlib.sha256(args.font.read_bytes()).hexdigest(),
"font_size": 32,
"preprocessing": {"color": "BGR", "image_shape": [3, 48, 320], "batch": 6, "normalization": "production rec"},
"samples": rows,
}
(destination / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
if __name__ == "__main__":
main()
+15
View File
@@ -0,0 +1,15 @@
"""Unit tests must not benchmark real GPUs or persist choices in the developer's config."""
from __future__ import annotations
import pytest
@pytest.fixture(autouse=True)
def isolate_onnx_device_selection(monkeypatch: pytest.MonkeyPatch) -> None:
"""Individual policy tests explicitly override this CPU default with isolated files."""
from mineru.config import config
from mineru.model.runtime import onnx_device
monkeypatch.setattr(config.model, "onnx_device", "cpu")
monkeypatch.setattr(onnx_device, "_selections", {})
@@ -2,7 +2,21 @@
import json
from pathlib import Path
from scripts.check_transformers5_dependencies import verify_wheels
import pytest
from scripts.check_transformers5_dependencies import check_backend_dependencies, verify_wheels
@pytest.mark.parametrize("platform", ["windows", "linux", "macos-intel"])
def test_runtime_and_magika_distribution_identity(platform: str) -> None:
"""求解成功后仍拒绝共享模块的发行包冲突及旧 Magika 依赖。"""
runtime = "onnxruntime-directml" if platform == "windows" else "onnxruntime"
other_runtime = "onnxruntime" if platform == "windows" else "onnxruntime-directml"
selected = {"gradio", runtime, "mineru-llama-cpp", "mineru-magika"}
assert check_backend_dependencies(selected, "mineru", "base", platform) == []
assert check_backend_dependencies(selected | {other_runtime}, "mineru", "base", platform)
assert check_backend_dependencies(selected | {"magika"}, "mineru", "base", platform)
assert check_backend_dependencies(selected - {"mineru-magika"}, "mineru", "base", platform)
def test_python_metadata_is_checked_without_binary_wheel_option(tmp_path: Path) -> None:
+127
View File
@@ -0,0 +1,127 @@
"""Production DML presets, explicit calibration sessions, serialization and MFR tails."""
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor
import threading
import time
from types import SimpleNamespace
from unittest.mock import Mock
import numpy as np
import pytest
from mineru.model.runtime import onnx
from mineru.model.runtime.onnx_device import OnnxSelection
from mineru.model.runtime.windows_gpu import GpuAdapter
@pytest.mark.parametrize(
("vendor", "enabled"),
[(0x8086, [False, True, False]), (0x1002, [True, True, True]), (0x10DE, [True, True, False]), (0xFFFF, [None, None, None])],
)
def test_vendor_presets_and_actual_index(monkeypatch: pytest.MonkeyPatch, vendor: int, enabled: list[bool | None]) -> None:
adapter = GpuAdapter(7, "luid", "gpu", vendor, 1, True, 0)
monkeypatch.setattr(onnx.ort, "get_available_providers", lambda: ["DmlExecutionProvider", "CPUExecutionProvider"])
native = SimpleNamespace(get_providers=lambda: ["DmlExecutionProvider", "CPUExecutionProvider"], disable_fallback=Mock())
factory = Mock(return_value=native)
monkeypatch.setattr(onnx.ort, "InferenceSession", factory)
for kind, preset in zip(("layout", "mfr", "ocr_rec"), enabled):
session = onnx.create_ort_session("test.onnx", adapter=adapter, model_kind=kind)
assert isinstance(session, onnx.DirectMLSession)
kwargs = factory.call_args.kwargs
options = kwargs["providers"][0][1]
assert options["device_id"] == 7
assert options.get("disable_metacommands") == (None if preset is None else "false" if preset else "true")
assert not kwargs["sess_options"].enable_mem_pattern
assert kwargs["sess_options"].execution_mode == onnx.ort.ExecutionMode.ORT_SEQUENTIAL
assert native.disable_fallback.call_count == 3
def test_cpu_models_do_not_select_gpu(monkeypatch: pytest.MonkeyPatch) -> None:
select = Mock(side_effect=AssertionError("selection"))
factory = Mock(return_value=SimpleNamespace(get_providers=lambda: ["CPUExecutionProvider"]))
monkeypatch.setattr(onnx, "resolve_onnx_device", select)
monkeypatch.setattr(onnx.ort, "InferenceSession", factory)
for kind in ("", "ocr_det", "seal_det", "table", "mfr_tail_cpu"):
onnx.ort_session("test.onnx", model_kind=kind)
select.assert_not_called()
assert all(call.kwargs["providers"] == onnx.ort_providers() for call in factory.call_args_list)
@pytest.mark.parametrize("failure", ["unavailable", "exception", "silent"])
def test_production_fallback_does_not_save_gpu(monkeypatch: pytest.MonkeyPatch, failure: str) -> None:
adapter = GpuAdapter(1, "id", "gpu", 0x8086, 1, False, 0)
cpu = SimpleNamespace(get_providers=lambda: ["CPUExecutionProvider"])
monkeypatch.setattr(
onnx.ort, "get_available_providers", lambda: [] if failure == "unavailable" else ["DmlExecutionProvider"]
)
factory = Mock(side_effect=[RuntimeError("gpu init"), cpu] if failure == "exception" else None, return_value=cpu)
monkeypatch.setattr(onnx.ort, "InferenceSession", factory)
save = Mock()
monkeypatch.setattr(onnx, "confirm_gpu_session", save)
assert onnx.ort_session("test.onnx", model_kind="layout", selection=OnnxSelection("gpu", adapter, "discrete_gpu")) is cpu
save.assert_not_called()
assert factory.call_args.kwargs["providers"] == onnx.ort_providers()
def test_calibration_factory_fails_instead_of_falling_back(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(onnx.ort, "get_available_providers", lambda: ["DmlExecutionProvider"])
monkeypatch.setattr(
onnx.ort, "InferenceSession", Mock(return_value=SimpleNamespace(get_providers=lambda: ["CPUExecutionProvider"]))
)
with pytest.raises(RuntimeError, match="fell back"):
onnx.create_ort_session("test.onnx", adapter=GpuAdapter(0, "id", "gpu", 1, 1, True, 0))
def test_run_is_serialized_per_session() -> None:
active = 0
maximum = 0
lock = threading.Lock()
def run(names: object, feed: object, options: object) -> list[int]:
nonlocal active, maximum
with lock:
active += 1
maximum = max(active, maximum)
time.sleep(0.01)
with lock:
active -= 1
return [1]
session = onnx.DirectMLSession(SimpleNamespace(run=run))
with ThreadPoolExecutor(max_workers=4) as pool:
assert list(pool.map(lambda _: session.run(None, {}), range(8))) == [[1]] * 8
assert maximum == 1
@pytest.mark.parametrize(("count", "batch"), [(1, 8), (7, 8), (8, 8), (9, 8), (16, 8), (17, 8), (8, 4)])
def test_mfr_only_actual_batch_eight_uses_gpu(monkeypatch: pytest.MonkeyPatch, count: int, batch: int) -> None:
from mineru.model.mfr import pp_formulanet_plus_m_onnx as mfr
model = object.__new__(mfr.PPFormulaNetPlusMONNX)
model.cpu_tail_enabled = True
model._cpu_tail_session = None
model._cpu_tail_lock = threading.Lock()
model._model_path = "mfr.onnx"
model._intra_op_num_threads = 4
model.input_name = "image"
gpu_calls, cpu_calls = [], []
def run(calls: list[int], feeds: dict[str, np.ndarray]) -> list[np.ndarray]:
tensor = feeds["image"]
calls.append(len(tensor))
return [tensor[:, 0, 0, 0].astype(np.int64)[:, None]]
model.session = SimpleNamespace(run=lambda names, feeds: run(gpu_calls, feeds))
factory = Mock(return_value=SimpleNamespace(run=lambda names, feeds: run(cpu_calls, feeds)))
monkeypatch.setattr(mfr, "ort_session", factory)
monkeypatch.setattr(model, "_preprocess", lambda crop: np.full((1, 1, 2, 2), crop[0, 0, 0]) if crop.size else None)
monkeypatch.setattr(model, "_decode_tokens", lambda tokens: str(tokens[0]))
crops = [np.full((value, value, 3), value, dtype=np.uint8) for value in range(count, 0, -1)]
for _ in range(2):
assert model._infer_batch(crops, batch_size=batch) == [str(value) for value in range(count, 0, -1)]
assert all(size == 8 for size in gpu_calls)
assert all(0 < size < 8 for size in cpu_calls)
assert sum(gpu_calls + cpu_calls) == count * 2
assert factory.call_count == (1 if cpu_calls else 0)
+45 -33
View File
@@ -69,7 +69,7 @@ doclib:
assert data["doclib"]["data_dir"] == str(tmp_path / "data")
assert data["doclib"]["tcp"]["enabled"] is True
assert data["doclib"]["tcp"]["port"] == 18080
assert data["doclib"]["sqlite"]["path"] == str(tmp_path / "data" / "doclib.db")
assert Path(data["doclib"]["sqlite"]["path"]) == tmp_path / "data" / "doclib.db"
def test_collect_env_overrides_uses_greedy_field_path_matching(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -327,13 +327,13 @@ def test_global_log_level_rejects_invalid_values(level: str) -> None:
def test_default_doclib_data_dir_uses_doclib_directory() -> None:
cfg = Config()
assert cfg.doclib.data_dir.endswith(".mineru/doclib")
assert Path(cfg.doclib.data_dir).as_posix().endswith(".mineru/doclib")
def test_default_model_config_uses_mineru_model_directory() -> None:
cfg = Config()
assert cfg.model.base_dir.endswith(".mineru/models")
assert Path(cfg.model.base_dir).as_posix().endswith(".mineru/models")
assert cfg.model.source == "auto"
@@ -483,12 +483,12 @@ def test_log_config_exposes_separate_log_paths() -> None:
assert defaults.stderr_path is None
assert defaults.parse_server_stdout_path is None
assert defaults.parse_server_stderr_path is None
assert defaults.resolved_app_path.endswith("logs/doclib.log")
assert defaults.resolved_access_path.endswith("logs/doclib.access.log")
assert defaults.resolved_stdout_path.endswith("logs/doclib.stdout.log")
assert defaults.resolved_stderr_path.endswith("logs/doclib.stderr.log")
assert defaults.resolved_parse_server_stdout_path.endswith("logs/doclib.parse-server.stdout.log")
assert defaults.resolved_parse_server_stderr_path.endswith("logs/doclib.parse-server.stderr.log")
assert Path(defaults.resolved_app_path).as_posix().endswith("logs/doclib.log")
assert Path(defaults.resolved_access_path).as_posix().endswith("logs/doclib.access.log")
assert Path(defaults.resolved_stdout_path).as_posix().endswith("logs/doclib.stdout.log")
assert Path(defaults.resolved_stderr_path).as_posix().endswith("logs/doclib.stderr.log")
assert Path(defaults.resolved_parse_server_stdout_path).as_posix().endswith("logs/doclib.parse-server.stdout.log")
assert Path(defaults.resolved_parse_server_stderr_path).as_posix().endswith("logs/doclib.parse-server.stderr.log")
cfg = Config(
doclib={
@@ -511,12 +511,12 @@ def test_log_config_exposes_separate_log_paths() -> None:
assert cfg.doclib.log.stderr_path == "/tmp/stderr.log"
assert cfg.doclib.log.parse_server_stdout_path == "/tmp/parse-server.stdout.log"
assert cfg.doclib.log.parse_server_stderr_path == "/tmp/parse-server.stderr.log"
assert cfg.doclib.log.resolved_app_path == "/tmp/app.log"
assert cfg.doclib.log.resolved_access_path == "/tmp/access.log"
assert cfg.doclib.log.resolved_stdout_path == "/tmp/stdout.log"
assert cfg.doclib.log.resolved_stderr_path == "/tmp/stderr.log"
assert cfg.doclib.log.resolved_parse_server_stdout_path == "/tmp/parse-server.stdout.log"
assert cfg.doclib.log.resolved_parse_server_stderr_path == "/tmp/parse-server.stderr.log"
assert Path(cfg.doclib.log.resolved_app_path).as_posix() == "/tmp/app.log"
assert Path(cfg.doclib.log.resolved_access_path).as_posix() == "/tmp/access.log"
assert Path(cfg.doclib.log.resolved_stdout_path).as_posix() == "/tmp/stdout.log"
assert Path(cfg.doclib.log.resolved_stderr_path).as_posix() == "/tmp/stderr.log"
assert Path(cfg.doclib.log.resolved_parse_server_stdout_path).as_posix() == "/tmp/parse-server.stdout.log"
assert Path(cfg.doclib.log.resolved_parse_server_stderr_path).as_posix() == "/tmp/parse-server.stderr.log"
def test_log_config_dir_derives_unspecified_log_paths() -> None:
@@ -526,12 +526,12 @@ def test_log_config_dir_derives_unspecified_log_paths() -> None:
assert cfg.access_path is None
assert cfg.stdout_path is None
assert cfg.stderr_path == "/tmp/custom-stderr.log"
assert cfg.resolved_app_path == "/tmp/mineru-logs/doclib.log"
assert cfg.resolved_access_path == "/tmp/mineru-logs/doclib.access.log"
assert cfg.resolved_stdout_path == "/tmp/mineru-logs/doclib.stdout.log"
assert cfg.resolved_stderr_path == "/tmp/custom-stderr.log"
assert cfg.resolved_parse_server_stdout_path == "/tmp/mineru-logs/doclib.parse-server.stdout.log"
assert cfg.resolved_parse_server_stderr_path == "/tmp/mineru-logs/doclib.parse-server.stderr.log"
assert Path(cfg.resolved_app_path).as_posix() == "/tmp/mineru-logs/doclib.log"
assert Path(cfg.resolved_access_path).as_posix() == "/tmp/mineru-logs/doclib.access.log"
assert Path(cfg.resolved_stdout_path).as_posix() == "/tmp/mineru-logs/doclib.stdout.log"
assert Path(cfg.resolved_stderr_path).as_posix() == "/tmp/custom-stderr.log"
assert Path(cfg.resolved_parse_server_stdout_path).as_posix() == "/tmp/mineru-logs/doclib.parse-server.stdout.log"
assert Path(cfg.resolved_parse_server_stderr_path).as_posix() == "/tmp/mineru-logs/doclib.parse-server.stderr.log"
def test_log_config_dir_override_derives_paths_in_deep_patches(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -548,24 +548,36 @@ def test_log_config_dir_override_derives_paths_in_deep_patches(monkeypatch: pyte
assert env_cfg.doclib.log.stderr_path is None
assert env_cfg.doclib.log.parse_server_stdout_path is None
assert env_cfg.doclib.log.parse_server_stderr_path is None
assert env_cfg.doclib.log.resolved_app_path == "/tmp/env-logs/doclib.log"
assert env_cfg.doclib.log.resolved_access_path == "/tmp/env-logs/doclib.access.log"
assert env_cfg.doclib.log.resolved_stdout_path == "/tmp/env-logs/doclib.stdout.log"
assert env_cfg.doclib.log.resolved_stderr_path == "/tmp/env-logs/doclib.stderr.log"
assert env_cfg.doclib.log.resolved_parse_server_stdout_path == "/tmp/env-logs/doclib.parse-server.stdout.log"
assert env_cfg.doclib.log.resolved_parse_server_stderr_path == "/tmp/env-logs/doclib.parse-server.stderr.log"
assert Path(env_cfg.doclib.log.resolved_app_path).as_posix() == "/tmp/env-logs/doclib.log"
assert Path(env_cfg.doclib.log.resolved_access_path).as_posix() == "/tmp/env-logs/doclib.access.log"
assert Path(env_cfg.doclib.log.resolved_stdout_path).as_posix() == "/tmp/env-logs/doclib.stdout.log"
assert Path(env_cfg.doclib.log.resolved_stderr_path).as_posix() == "/tmp/env-logs/doclib.stderr.log"
assert (
Path(env_cfg.doclib.log.resolved_parse_server_stdout_path).as_posix()
== "/tmp/env-logs/doclib.parse-server.stdout.log"
)
assert (
Path(env_cfg.doclib.log.resolved_parse_server_stderr_path).as_posix()
== "/tmp/env-logs/doclib.parse-server.stderr.log"
)
assert patched_cfg.doclib.log.app_path is None
assert patched_cfg.doclib.log.access_path is None
assert patched_cfg.doclib.log.stdout_path is None
assert patched_cfg.doclib.log.stderr_path is None
assert patched_cfg.doclib.log.parse_server_stdout_path is None
assert patched_cfg.doclib.log.parse_server_stderr_path is None
assert patched_cfg.doclib.log.resolved_app_path == "/tmp/patched-logs/doclib.log"
assert patched_cfg.doclib.log.resolved_access_path == "/tmp/patched-logs/doclib.access.log"
assert patched_cfg.doclib.log.resolved_stdout_path == "/tmp/patched-logs/doclib.stdout.log"
assert patched_cfg.doclib.log.resolved_stderr_path == "/tmp/patched-logs/doclib.stderr.log"
assert patched_cfg.doclib.log.resolved_parse_server_stdout_path == "/tmp/patched-logs/doclib.parse-server.stdout.log"
assert patched_cfg.doclib.log.resolved_parse_server_stderr_path == "/tmp/patched-logs/doclib.parse-server.stderr.log"
assert Path(patched_cfg.doclib.log.resolved_app_path).as_posix() == "/tmp/patched-logs/doclib.log"
assert Path(patched_cfg.doclib.log.resolved_access_path).as_posix() == "/tmp/patched-logs/doclib.access.log"
assert Path(patched_cfg.doclib.log.resolved_stdout_path).as_posix() == "/tmp/patched-logs/doclib.stdout.log"
assert Path(patched_cfg.doclib.log.resolved_stderr_path).as_posix() == "/tmp/patched-logs/doclib.stderr.log"
assert (
Path(patched_cfg.doclib.log.resolved_parse_server_stdout_path).as_posix()
== "/tmp/patched-logs/doclib.parse-server.stdout.log"
)
assert (
Path(patched_cfg.doclib.log.resolved_parse_server_stderr_path).as_posix()
== "/tmp/patched-logs/doclib.parse-server.stderr.log"
)
def test_interval_and_timeout_config_is_startup_config_not_runtime_kv() -> None:
+15 -7
View File
@@ -205,10 +205,13 @@ def test_standard_dependency_errors_survive_startup_wrappers(
assert doclib_error.value.param == "value"
def _resolved_direct_dependencies(platform: str, machine: str, extra: str) -> set[str]:
def _resolved_direct_dependencies(platform: str, machine: str, extra: str, python: str = "3.14") -> set[str]:
"""按目标平台展开本项目的递归 extra,不使用宿主环境的标记值。"""
project = tomllib.loads((Path(__file__).resolve().parents[2] / "pyproject.toml").read_text(encoding="utf-8"))["project"]
environment = {**default_environment(), "sys_platform": platform, "platform_machine": machine}
environment = {
**default_environment(), "sys_platform": platform, "platform_machine": machine,
"python_version": python, "python_full_version": python + ".0",
}
pending, visited, names = [extra], set(), set()
while pending:
current = pending.pop()
@@ -228,13 +231,18 @@ def _resolved_direct_dependencies(platform: str, machine: str, extra: str) -> se
@pytest.mark.parametrize(
"platform,machine", [("darwin", "arm64"), ("darwin", "x86_64"), ("linux", "x86_64"), ("win32", "AMD64")]
"platform,machine",
[("darwin", "arm64"), ("darwin", "x86_64"), ("linux", "x86_64"), ("win32", "AMD64"),
("win32", "amd64"), ("win32", "x86_64"), ("win32", "ARM64")],
)
@pytest.mark.parametrize("extra", ["", "torch", "full", "all"])
def test_package_markers(platform: str, machine: str, extra: str) -> None:
@pytest.mark.parametrize("python", ["3.10", "3.11", "3.12", "3.13", "3.14"])
def test_package_markers(platform: str, machine: str, extra: str, python: str) -> None:
"""ARM 基础包引入完整 Torch 依赖,其他平台基础包保持 ONNX 与 llama。"""
names = _resolved_direct_dependencies(platform, machine, extra)
assert {"docvortex", "gradio", "onnxruntime", "mineru-llama-cpp"} <= names
names = _resolved_direct_dependencies(platform, machine, extra, python)
runtime = "onnxruntime-directml" if platform == "win32" and machine in {"AMD64", "amd64", "x86_64"} else "onnxruntime"
assert {"docvortex", "gradio", runtime, "mineru-llama-cpp"} <= names
assert not {"onnxruntime", "onnxruntime-directml"} <= names
# lxml 由 DocVortex 声明,不在 MinerU 运行时 extras 中重复维护。
assert "lxml" not in names
expected_torch = (platform, machine) == ("darwin", "arm64") or extra in {"torch", "full", "all"}
@@ -249,7 +257,7 @@ def test_package_markers(platform: str, machine: str, extra: str) -> None:
def test_real_resolution_policy_keeps_explicit_utils_mlx_separate() -> None:
"""真实 wheel 检查允许 ARM 基础 Torch,但不把 utils 的显式 MLX 当成 MinerU 默认安装。"""
names = _resolved_direct_dependencies("darwin", "arm64", "")
names = _resolved_direct_dependencies("darwin", "arm64", "") | {"mineru-magika"}
assert check_backend_dependencies(names, "mineru", "base", "macos") == []
# 完整求解结果允许包含 DocVortex 带入的传递依赖。
assert check_backend_dependencies(names | {"lxml"}, "mineru", "base", "macos") == []
+146
View File
@@ -0,0 +1,146 @@
"""Validate the portable benchmark protocol and data-derived threshold selection."""
from __future__ import annotations
import copy
import json
from pathlib import Path
import subprocess
from types import SimpleNamespace
from unittest.mock import Mock
import numpy as np
import pytest
from mineru.model.runtime import onnx_calibration as calibration
from mineru.model.runtime.windows_gpu import GpuAdapter
from scripts.benchmark_onnx_selection import derive_threshold
def gpu() -> GpuAdapter:
return GpuAdapter(2, "id", "test", 0x8086, 1, True, 0)
def test_installed_samples_are_fixed_and_complete() -> None:
first, metadata = calibration.prepare_input()
second, repeated = calibration.prepare_input()
assert first.shape[:3] == (6, 3, 48)
assert first.dtype == np.float32
assert np.array_equal(first, second)
assert metadata == repeated
manifest = json.loads((calibration.sample_directory() / "manifest.json").read_text(encoding="utf-8"))
text = "".join(row["text"] for row in manifest["samples"])
assert any("\u4e00" <= character <= "\u9fff" for character in text)
assert any(character.isascii() and character.isalpha() for character in text)
assert any(character.isdigit() for character in text)
def test_corrupt_sample_is_rejected(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
manifest = json.loads((calibration.sample_directory() / "manifest.json").read_text(encoding="utf-8"))
(tmp_path / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
(tmp_path / manifest["samples"][0]["file"]).write_bytes(b"corrupt")
monkeypatch.setattr(calibration, "sample_directory", lambda: tmp_path)
with pytest.raises(ValueError, match="checksum"):
calibration.prepare_input()
def test_measurement_reuses_input_and_excludes_warmup(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
from mineru.model.runtime import onnx
tensor = np.zeros((6, 3, 48, 320), dtype=np.float32)
feeds = []
providers = []
monkeypatch.setattr(calibration, "prepare_input", lambda: (tensor, {"shape": list(tensor.shape)}))
ticks = iter([0, 4, 10, 110, 200, 202, 300, 303, 400, 408, 500, 600, 700, 701, 800, 802])
monkeypatch.setattr(calibration.time, "perf_counter", lambda: next(ticks))
model = tmp_path / "model.onnx"
model.write_bytes(b"fake weights")
def create(path: str, **kwargs: object) -> SimpleNamespace:
provider = "DmlExecutionProvider" if kwargs["adapter"] else "CPUExecutionProvider"
providers.append(provider)
def run(names: object, feed: dict[str, np.ndarray]) -> list[np.ndarray]:
feeds.append(feed["image"])
return [np.ones((6, 3, 4), dtype=np.float32)]
return SimpleNamespace(get_inputs=lambda: [SimpleNamespace(name="image")], run=run, get_providers=lambda: [provider])
monkeypatch.setattr(onnx, "create_ort_session", create)
result = calibration.measure_rec(str(model), gpu())
assert providers == ["CPUExecutionProvider", "DmlExecutionProvider"]
assert len(feeds) == 6 and all(feed is tensor for feed in feeds)
assert result["cpu_elapsed"] == 5
assert result["gpu_elapsed"] == 3
assert result["cpu"]["first_s"] == result["gpu"]["first_s"] == 100
assert result["cpu"]["init_s"] == 4 and result["gpu"]["init_s"] == 8
def test_worker_timeout_is_bounded(monkeypatch: pytest.MonkeyPatch) -> None:
runner = Mock(side_effect=subprocess.TimeoutExpired("worker", 15))
monkeypatch.setattr(calibration.subprocess, "run", runner)
with pytest.raises(subprocess.TimeoutExpired):
calibration.run_calibration("rec.onnx", gpu())
assert runner.call_args.kwargs["timeout"] == 15
assert runner.call_args.kwargs["env"]["MINERU_MODEL_ONNX_DEVICE"] == "cpu"
def test_worker_silent_fallback_is_not_a_measurement(monkeypatch: pytest.MonkeyPatch) -> None:
output = {"cpu_elapsed": 1, "gpu_elapsed": 0.1, "outputs_match": True, "gpu": {"providers": ["CPUExecutionProvider"]}}
monkeypatch.setattr(
calibration.subprocess, "run", Mock(return_value=SimpleNamespace(returncode=0, stdout=json.dumps(output)))
)
with pytest.raises(ValueError, match="DirectML"):
calibration.run_calibration("rec.onnx", gpu())
def reports() -> list[dict]:
result = []
for label, ratios in (
("ultra5", [0.5, 0.52, 0.55, 0.53, 0.51]),
("i7", [1.2, 1.3, 1.1, 1.4, 1.3]),
("max395", [0.3, 0.32, 0.29, 0.33, 0.31]),
):
runs = [
{
"cpu_elapsed": 1.0,
"gpu_elapsed": ratio,
"outputs_match": True,
"gpu": {"providers": ["DmlExecutionProvider"]},
"input": {"manifest_sha256": "samples", "input_sha256": "input"},
"model_sha256": "model",
"ort_version": "1.24.4",
"intra_threads": 4,
"inter_threads": 1,
}
for ratio in ratios
]
result.append({"label": label, "runs": runs, "errors": [], "threshold": None})
return result
def test_threshold_is_derived_from_all_five_runs() -> None:
result = derive_threshold(reports())
assert result["lower"] == 0.55
assert result["upper"] == 1.0
assert result["threshold"] == 0.775
assert result["status"] == "awaiting_independent_validation"
@pytest.mark.parametrize("failure", ["missing_host", "missing_run", "different_input", "different_ort", "failed", "overlap"])
def test_invalid_calibration_set_is_rejected(failure: str) -> None:
data = copy.deepcopy(reports())
if failure == "missing_host":
data.pop()
elif failure == "missing_run":
data[0]["runs"].pop()
elif failure == "different_input":
data[0]["runs"][0]["input"]["input_sha256"] = "other"
elif failure == "different_ort":
data[0]["runs"][0]["ort_version"] = "1.30.0"
elif failure == "failed":
data[0]["errors"] = ["failed"]
else:
data[0]["runs"][0]["gpu_elapsed"] = 1.5
with pytest.raises(ValueError):
derive_threshold(data)
@@ -0,0 +1,226 @@
"""Device policy, one-time persistence, and calibration failures without GPU hardware."""
from __future__ import annotations
import importlib
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
import yaml
from pydantic import ValidationError
from mineru.config import Config
from mineru.model.runtime import onnx_device as policy
from mineru.model.runtime.windows_gpu import GpuAdapter, preferred_gpu
settings = importlib.import_module("mineru.config")
def adapter(*, index: int = 0, uma: bool | None = True, vendor: int = 0x8086, rank: int = 0) -> GpuAdapter:
return GpuAdapter(index, f"luid-{index}", "test GPU", vendor, 1, uma, rank)
@pytest.fixture
def config_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
target = tmp_path / "config.yaml"
target.write_text("model:\n onnx_device: auto\n source: local\n", encoding="utf-8")
monkeypatch.setattr(settings, "get_config_file_path", lambda: str(target))
monkeypatch.setattr(settings, "get_config_source", lambda key: "file")
monkeypatch.setattr(settings.config.model, "onnx_device", "auto")
monkeypatch.setattr(policy, "sys", SimpleNamespace(platform="win32"))
monkeypatch.setattr(policy, "_selections", {})
monkeypatch.setattr(policy, "ONNX_GPU_TIME_RATIO_THRESHOLD", 0.75)
monkeypatch.setattr(policy, "discover_gpus", lambda **_: (adapter(),))
import onnxruntime
from mineru.model import registry
from mineru.model.runtime import onnx_calibration
monkeypatch.setattr(onnxruntime, "get_available_providers", lambda: ["DmlExecutionProvider", "CPUExecutionProvider"])
monkeypatch.setattr(registry, "MINERU_4_MODELS_ONNX", SimpleNamespace(ocr_rec=SimpleNamespace(ensure=lambda: "rec.onnx")))
monkeypatch.setattr(onnx_calibration, "run_calibration", Mock(return_value={"cpu_elapsed": 2.0, "gpu_elapsed": 1.0}))
return target
def test_config_values_and_environment(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
assert Config().model.onnx_device == "auto"
for value in ("auto", "cpu", "gpu"):
assert Config(model={"onnx_device": value}).model.onnx_device == value
with pytest.raises(ValidationError):
Config(model={"onnx_device": "cuda"})
path = tmp_path / "config.yaml"
path.write_text("model:\n onnx_device: cpu\n", encoding="utf-8")
monkeypatch.setenv("MINERU_CONFIG", str(path))
monkeypatch.setenv("MINERU_MODEL_ONNX_DEVICE", "gpu")
loaded = settings._load_effective_config()
assert loaded.config.model.onnx_device == "gpu"
assert loaded.sources[("model", "onnx_device")] == "env"
def test_discrete_priority_and_rank_mapping() -> None:
igpu = adapter(index=0, rank=0)
discrete = adapter(index=4, uma=False, rank=1)
external = adapter(index=7, uma=False, vendor=0x10DE, rank=0)
assert preferred_gpu((igpu, discrete, external)) == external
assert preferred_gpu((igpu, discrete)) == discrete # Intel discrete is still discrete.
assert preferred_gpu(()) is None
assert preferred_gpu((adapter(index=2, uma=None, rank=1), igpu)) == igpu
def test_gpu_mode_uses_performance_order_without_creating_devices() -> None:
import ctypes
from mineru.model.runtime import windows_gpu
create = Mock(return_value=1) # D3D12CreateDevice returns S_FALSE for a successful capability check.
assert windows_gpu._architecture(ctypes.c_void_p(1), create, classify=False) == (True, None)
assert create.call_args.args[-1] is None
igpu = adapter(index=0, uma=None, rank=1)
discrete = adapter(index=1, uma=None, vendor=0x10DE, rank=0)
assert preferred_gpu((igpu, discrete)) == discrete
def test_gpu_mode_requests_fast_probe(config_file: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings.config.model, "onnx_device", "gpu")
discover = Mock(return_value=(adapter(uma=None),))
monkeypatch.setattr(policy, "discover_gpus", discover)
assert policy.resolve_onnx_device().device == "gpu"
discover.assert_called_once_with(classify=False)
@pytest.mark.parametrize(("gpu_time", "expected"), [(0.74, True), (0.75, True), (0.751, False)])
def test_threshold_boundary(gpu_time: float, expected: bool) -> None:
assert policy.choose_gpu(1.0, gpu_time, 0.75) is expected
@pytest.mark.parametrize("values", [(0, 1, 0.75), (1, -1, 0.75), (1, float("nan"), 0.75), (1, 1, 1.0)])
def test_invalid_timings(values: tuple[float, float, float]) -> None:
with pytest.raises(ValueError):
policy.choose_gpu(*values)
def test_cpu_never_discovers_or_benchmarks(config_file: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings.config.model, "onnx_device", "cpu")
discover = Mock(side_effect=AssertionError("discovery"))
monkeypatch.setattr(policy, "discover_gpus", discover)
assert policy.resolve_onnx_device().device == "cpu"
discover.assert_not_called()
@pytest.mark.parametrize(("gpu_time", "expected"), [(1.0, "gpu"), (1.6, "cpu")])
def test_calibration_saved_once(config_file: Path, monkeypatch: pytest.MonkeyPatch, gpu_time: float, expected: str) -> None:
from mineru.model.runtime import onnx_calibration
measure = Mock(return_value={"cpu_elapsed": 2.0, "gpu_elapsed": gpu_time})
monkeypatch.setattr(onnx_calibration, "run_calibration", measure)
assert policy.resolve_onnx_device().device == expected
assert policy.resolve_onnx_device().device == expected
assert measure.call_count == 1
assert yaml.safe_load(config_file.read_text())["model"] == {"onnx_device": expected, "source": "local"}
policy._selections.clear() # A new process reads the persisted choice instead of benchmarking again.
assert policy.resolve_onnx_device().device == expected
assert measure.call_count == 1
def test_discrete_skips_calibration_and_persists_only_after_session(config_file: Path, monkeypatch: pytest.MonkeyPatch) -> None:
from mineru.model.runtime import onnx_calibration
monkeypatch.setattr(policy, "discover_gpus", lambda **_: (adapter(uma=False),))
result = policy.resolve_onnx_device()
assert result.device == "gpu"
onnx_calibration.run_calibration.assert_not_called()
assert yaml.safe_load(config_file.read_text())["model"]["onnx_device"] == "auto"
policy.confirm_gpu_session(result)
assert yaml.safe_load(config_file.read_text())["model"]["onnx_device"] == "gpu"
@pytest.mark.parametrize("failure", [RuntimeError("GPU failed"), TimeoutError("worker timeout")])
def test_failures_do_not_save_or_retry(config_file: Path, monkeypatch: pytest.MonkeyPatch, failure: Exception) -> None:
from mineru.model.runtime import onnx_calibration
measure = Mock(side_effect=failure)
monkeypatch.setattr(onnx_calibration, "run_calibration", measure)
assert policy.resolve_onnx_device().device == "cpu"
assert policy.resolve_onnx_device().device == "cpu"
assert measure.call_count == 1
assert yaml.safe_load(config_file.read_text())["model"]["onnx_device"] == "auto"
def test_environment_choice_is_not_saved(config_file: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "get_config_source", lambda key: "env")
assert policy.resolve_onnx_device().device == "gpu"
assert yaml.safe_load(config_file.read_text())["model"]["onnx_device"] == "auto"
def test_user_edit_wins_compare_and_swap(config_file: Path) -> None:
config_file.write_text("model:\n onnx_device: cpu\n source: local\n", encoding="utf-8")
assert not settings.update_config_file({"model": {"onnx_device": "gpu"}}, only_if_onnx_auto=True)
assert yaml.safe_load(config_file.read_text())["model"]["onnx_device"] == "cpu"
assert policy.resolve_onnx_device().device == "cpu"
def test_unwritable_config_keeps_process_choice(config_file: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "update_config_file", Mock(side_effect=PermissionError("read only")))
assert policy.resolve_onnx_device().device == "gpu"
assert yaml.safe_load(config_file.read_text())["model"]["onnx_device"] == "auto"
@pytest.mark.parametrize("mode", ["no_gpu", "no_provider", "discovery_error", "pending", "non_windows"])
def test_discovery_and_unavailable_states(config_file: Path, monkeypatch: pytest.MonkeyPatch, mode: str) -> None:
import onnxruntime
if mode == "no_gpu":
monkeypatch.setattr(policy, "discover_gpus", lambda **_: ())
elif mode == "no_provider":
monkeypatch.setattr(onnxruntime, "get_available_providers", lambda: ["CPUExecutionProvider"])
elif mode == "discovery_error":
monkeypatch.setattr(policy, "discover_gpus", Mock(side_effect=OSError("DXGI error")))
elif mode == "pending":
monkeypatch.setattr(policy, "ONNX_GPU_TIME_RATIO_THRESHOLD", None)
else:
monkeypatch.setattr(policy, "sys", SimpleNamespace(platform="linux"))
assert policy.resolve_onnx_device().device == "cpu"
assert yaml.safe_load(config_file.read_text())["model"]["onnx_device"] == ("cpu" if mode == "no_gpu" else "auto")
def test_manual_gpu_retained_without_gpu(config_file: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings.config.model, "onnx_device", "gpu")
config_file.write_text("model:\n onnx_device: gpu\n", encoding="utf-8")
monkeypatch.setattr(policy, "discover_gpus", lambda **_: ())
assert policy.resolve_onnx_device().device == "cpu"
assert yaml.safe_load(config_file.read_text())["model"]["onnx_device"] == "gpu"
def test_waiting_process_rechecks_choice_after_lock(config_file: Path, monkeypatch: pytest.MonkeyPatch) -> None:
from mineru.model.runtime import onnx_calibration
class OtherProcessSaved:
def acquire(self) -> None:
config_file.write_text("model:\n onnx_device: cpu\n", encoding="utf-8")
def release(self) -> None:
pass
monkeypatch.setattr(policy, "FileLock", lambda *args, **kwargs: OtherProcessSaved())
assert policy.resolve_onnx_device().device == "cpu"
onnx_calibration.run_calibration.assert_not_called()
def test_concurrent_model_initializers_share_one_decision(config_file: Path) -> None:
from concurrent.futures import ThreadPoolExecutor
from mineru.model.runtime import onnx_calibration
with ThreadPoolExecutor(max_workers=4) as pool:
choices = list(pool.map(lambda _: policy.resolve_onnx_device(), range(8)))
assert all(choice is choices[0] for choice in choices)
assert onnx_calibration.run_calibration.call_count == 1
def test_manual_reset_to_auto_recalibrates(config_file: Path) -> None:
from mineru.model.runtime import onnx_calibration
policy.resolve_onnx_device()
settings.update_config_file({"model": {"onnx_device": "auto"}})
policy._selections.clear() # New process after the explicit reset.
policy.resolve_onnx_device()
assert onnx_calibration.run_calibration.call_count == 2
+5 -2
View File
@@ -107,7 +107,7 @@ def test_ocr_txt_dictionary_ctc_space_and_shape_validation(monkeypatch: pytest.M
get_inputs=lambda: [SimpleNamespace(name="image")],
get_outputs=lambda: [SimpleNamespace(shape=["batch", "width", 4])],
)
monkeypatch.setattr(ocr, "ort_session", lambda *args: session)
monkeypatch.setattr(ocr, "ort_session", lambda *args, **kwargs: session)
recognizer = ocr.TextRecognizerONNX("rec.onnx", str(dictionary))
logits = np.eye(4, dtype=np.float32)[[1, 1, 0, 2, 3, 3, 0, 1]]
assert recognizer._decode(logits) == ("中A 中", 1.0)
@@ -116,7 +116,7 @@ def test_ocr_txt_dictionary_ctc_space_and_shape_validation(monkeypatch: pytest.M
dictionary.write_text("中\nA\nB\n", encoding="utf-8")
with pytest.raises(ValueError, match="dictionary requires"):
ocr.TextRecognizerONNX("rec.onnx", str(dictionary))
assert len(PPOCRV6_DICT_PATH.read_text().splitlines()) == 18708
assert len(PPOCRV6_DICT_PATH.read_text(encoding="utf-8").splitlines()) == 18708
def test_formula_invalid_boxes_do_not_shift_latex_between_items(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -124,6 +124,7 @@ def test_formula_invalid_boxes_do_not_shift_latex_between_items(monkeypatch: pyt
from mineru.model.mfr.pp_formulanet_plus_m_onnx import PPFormulaNetPlusMONNX
model = object.__new__(PPFormulaNetPlusMONNX)
model.cpu_tail_enabled = False
monkeypatch.setattr(model, "_infer_batch", lambda crops, batch_size: ["first", "second"])
source = [
[{"label": "display_formula", "bbox": [-8, 0, -1, 5]}, {"label": "inline_formula", "bbox": [1, 1, 8, 8]}],
@@ -165,6 +166,7 @@ def test_formula_eos_stops_before_trailing_tokens() -> None:
from mineru.model.mfr.pp_formulanet_plus_m_onnx import PPFormulaNetPlusMONNX
model = object.__new__(PPFormulaNetPlusMONNX)
model.cpu_tail_enabled = False
model.decoder = Mock(return_value=["x"])
assert model._decode_tokens(np.array([[0, 17, 2, 99, 1]])) == "x"
np.testing.assert_array_equal(model.decoder.call_args.args[0], [[0, 17, 2]])
@@ -333,6 +335,7 @@ def test_formula_cpu_batch_eight_sorts_and_restores_order(monkeypatch: pytest.Mo
from mineru.model.mfr.pp_formulanet_plus_m_onnx import PPFormulaNetPlusMONNX
model = object.__new__(PPFormulaNetPlusMONNX)
model.cpu_tail_enabled = False
model.input_name = "image"
calls = []
+11
View File
@@ -122,6 +122,17 @@ def test_explicit_cpu_overrides_available_cuda_provider(monkeypatch: pytest.Monk
assert factory.call_args.kwargs["providers"] == onnx.ort_providers()
def test_explicit_onnx_stack_keeps_tables_cpu_even_with_cuda(monkeypatch: pytest.MonkeyPatch) -> None:
"""Per-model backend overrides must win over a globally selected Torch backend."""
monkeypatch.setenv("MINERU_TABLE_DEVICE", "cuda")
monkeypatch.setattr(onnx.ort, "get_available_providers", lambda: ["CUDAExecutionProvider", "CPUExecutionProvider"])
session = SimpleNamespace(get_providers=lambda: ["CPUExecutionProvider"])
factory = Mock(return_value=session)
monkeypatch.setattr(onnx.ort, "InferenceSession", factory)
assert onnx.table_ort_session("table.onnx", small_backend="onnx") is session
assert factory.call_args.kwargs["providers"] == onnx.ort_providers()
@pytest.mark.parametrize("device", ["auto", "cuda"])
@pytest.mark.parametrize("mode", ["unavailable", "exception", "silent"])
def test_cuda_failure_is_reported_and_cpu_remains_available(monkeypatch: pytest.MonkeyPatch, mode: str, device: str) -> None: