flowocr 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flowocr/__init__.py +15 -0
- flowocr/analyze/__init__.py +8 -0
- flowocr/analyze/align.py +151 -0
- flowocr/analyze/build_tracks.py +2886 -0
- flowocr/analyze/cluster_layers.py +155 -0
- flowocr/analyze/game_align.py +652 -0
- flowocr/analyze/gamescript.py +1220 -0
- flowocr/analyze/gtdbundle.py +306 -0
- flowocr/analyze/match.py +67 -0
- flowocr/analyze/matchers/__init__.py +5 -0
- flowocr/analyze/matchers/gametext.py +70 -0
- flowocr/analyze/merge_nameplate.py +178 -0
- flowocr/analyze/models/slot_pair.json +148 -0
- flowocr/analyze/nameplate.py +148 -0
- flowocr/analyze/pair_features.py +168 -0
- flowocr/analyze/pair_model.py +127 -0
- flowocr/analyze/refine_boundaries.py +409 -0
- flowocr/analyze/script_align.py +641 -0
- flowocr/analyze/scriptmatch.py +1018 -0
- flowocr/analyze/slot_learned.py +306 -0
- flowocr/analyze/slot_lines.py +251 -0
- flowocr/analyze/slot_modes.py +143 -0
- flowocr/analyze/slot_pairs.py +562 -0
- flowocr/analyze/slot_veto.py +104 -0
- flowocr/analyze/uigate.py +351 -0
- flowocr/artifacts/__init__.py +5 -0
- flowocr/artifacts/evalkit.py +123 -0
- flowocr/artifacts/matchedio.py +75 -0
- flowocr/artifacts/srtio.py +166 -0
- flowocr/artifacts/tracksio.py +345 -0
- flowocr/extensions.py +77 -0
- flowocr/extract/__init__.py +8 -0
- flowocr/extract/childproc.py +118 -0
- flowocr/extract/decode_proc.py +511 -0
- flowocr/extract/decode_shards.py +574 -0
- flowocr/extract/detpost.py +215 -0
- flowocr/extract/edge_proc.py +314 -0
- flowocr/extract/edge_refine.py +598 -0
- flowocr/extract/fast_det.py +214 -0
- flowocr/extract/ffcheck.py +87 -0
- flowocr/extract/framegrid.py +265 -0
- flowocr/extract/framesource.py +1264 -0
- flowocr/extract/ocr_args.py +754 -0
- flowocr/extract/ocr_complete.py +231 -0
- flowocr/extract/ocr_parallel.py +208 -0
- flowocr/extract/ort_server.py +632 -0
- flowocr/extract/ortclient.py +363 -0
- flowocr/extract/ptsclock.py +150 -0
- flowocr/extract/recdecode.py +78 -0
- flowocr/extract/recort.py +162 -0
- flowocr/extract/recpack.py +94 -0
- flowocr/extract/recpool.py +206 -0
- flowocr/extract/recprep.py +71 -0
- flowocr/extract/refine_video.py +271 -0
- flowocr/extract/regions.py +387 -0
- flowocr/extract/reuse_v2.py +593 -0
- flowocr/extract/run_groups.py +247 -0
- flowocr/extract/run_ocr2.py +1605 -0
- flowocr/extract/supervisor.py +261 -0
- flowocr/extract/timeline.py +84 -0
- flowocr/extract/typewriter_fuse.py +394 -0
- flowocr/models.py +263 -0
- flowocr/output/__init__.py +3 -0
- flowocr/output/export.py +205 -0
- flowocr/output/layout.py +210 -0
- flowocr/output/presets/__init__.py +6 -0
- flowocr/output/presets/_overlay.py +40 -0
- flowocr/output/presets/default.py +21 -0
- flowocr/output/presets/default_all.py +20 -0
- flowocr/output/presets/dev.py +21 -0
- flowocr/output/presets/matched_srt.py +32 -0
- flowocr/output/presets/script.py +20 -0
- flowocr/output/presets/srt_main.py +33 -0
- flowocr/output/render.py +82 -0
- flowocr/output/run_srt.py +83 -0
- flowocr/output/script.py +541 -0
- flowocr/paths.py +168 -0
- flowocr/provenance.py +119 -0
- flowocr/typeset/__init__.py +9 -0
- flowocr/typeset/__main__.py +38 -0
- flowocr/typeset/assfile.py +216 -0
- flowocr/typeset/core.py +821 -0
- flowocr/typeset/fx/__init__.py +11 -0
- flowocr-0.1.0.dist-info/METADATA +109 -0
- flowocr-0.1.0.dist-info/RECORD +91 -0
- flowocr-0.1.0.dist-info/WHEEL +5 -0
- flowocr-0.1.0.dist-info/entry_points.txt +10 -0
- flowocr-0.1.0.dist-info/licenses/LICENSE +674 -0
- flowocr-0.1.0.dist-info/licenses/LICENSES/Apache-2.0.txt +201 -0
- flowocr-0.1.0.dist-info/licenses/LICENSES/PP-OCRv6-NOTICE.md +18 -0
- flowocr-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,632 @@
|
|
|
1
|
+
"""ONNX Runtime 的**常驻 worker**:独立进程,靠共享内存和调用方交换张量。
|
|
2
|
+
用哪个解释器起它由 `ortclient.server_python()` 定(`FLOWOCR_ORT_PYTHON` > 当前解释器自己装了 onnxruntime > 旧的 onnxrt 实验 venv)。
|
|
3
|
+
|
|
4
|
+
一个进程可以同时服务**多个模型**(`--model 名字=路径` 可给多次):管线里 det 和 rec 都走它,
|
|
5
|
+
省一份显存、也少一层 CUDA 上下文切换。
|
|
6
|
+
|
|
7
|
+
为什么推理在单独的进程里:最早是为了不和 Paddle 同进程(两个推理运行时各自加载 cuDNN / cuBLAS,版本互相踩);
|
|
8
|
+
2026-10 运行时去掉 Paddle 之后照旧——管线进程的 GIL 不被推理和前处理抢,一个服务同时接 det 和几个 rec 在途。
|
|
9
|
+
代价是每次调用一次进程间往返,**实测 0.12~0.33 ms = 2%~3%**(一次性探针 `probe_ort_rec_ipc.py`)。
|
|
10
|
+
|
|
11
|
+
**每个形状一个 session**(`--per-shape`,默认开):ORT 的 CUDA EP **每次形状变化**都要重新规划,
|
|
12
|
+
**不是只在第一次见到的时候**——同宽连打 4.6 ms、8 个宽轮转 **81 ms**,预热过也还是 80 ms。
|
|
13
|
+
每个形状各给一个 session 之后回到 5.2 ms。代价是每个 session 一份权重(rec fp32 73 MB)和一份自己的 arena,`--share-mem` 让它们共用(约 155 MB → 50 MB)。
|
|
14
|
+
所以调用方**必须**把形状收敛到少数几个(rec 那边是把张量宽取整到 320 的倍数 + 批取到 2 的幂)。
|
|
15
|
+
|
|
16
|
+
协议(够用就好,不做通用 RPC):
|
|
17
|
+
* 调用方建一块共享内存,输入张量原样写进前半段(float32、C 序);
|
|
18
|
+
* 一行 JSON 进来:`{"model": 名字, "shape": [...]}`;或者 rec 送**原样的 uint8 裁剪**(依次紧排):
|
|
19
|
+
`{"model": 名字, "crops": [[h, w, 3], …], "grid": 320, "batch": k}`,前处理在这边做(`recprep.batch`,握手里 `pre` 为真才能这么送);
|
|
20
|
+
* 结果按顺序写进后半段(`--in-bytes` 之后),回一行
|
|
21
|
+
`{"outs": [[dtype, shape], …], "infer_ms": …}`——调用方按这个自己切;
|
|
22
|
+
* `{"bye": 1}` 退出。
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import argparse
|
|
27
|
+
import contextlib
|
|
28
|
+
import json
|
|
29
|
+
import sys
|
|
30
|
+
import threading
|
|
31
|
+
import time
|
|
32
|
+
from multiprocessing import shared_memory
|
|
33
|
+
|
|
34
|
+
import numpy as np
|
|
35
|
+
import onnxruntime as ort
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def norm_device(d: str) -> str:
|
|
39
|
+
"""`cpu` / `gpu` / `gpu:N` -> `cpu` / `gpu:N`(`gpu` 就是 0 号卡)。这个文件可能跑在别的解释器里、不 import flowocr,
|
|
40
|
+
所以口径在这里自己写一份;和 `ocr_args.ort_device` 的输出是同一种字符串,握手按它逐字比。"""
|
|
41
|
+
d = (d or "").strip().lower()
|
|
42
|
+
if d == "cpu":
|
|
43
|
+
return "cpu"
|
|
44
|
+
if d == "gpu":
|
|
45
|
+
return "gpu:0"
|
|
46
|
+
if d.startswith("gpu:") and d[4:].isdigit():
|
|
47
|
+
return f"gpu:{int(d[4:])}"
|
|
48
|
+
raise SystemExit(f"--device 只认 cpu / gpu / gpu:N:{d!r}")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def load_recprep():
|
|
52
|
+
"""按**文件路径**加载同目录的 `recprep.py`(rec 的前处理),不经过 `flowocr.extract` 的包初始化
|
|
53
|
+
(这个进程可能跑在别的解释器里,也可能没有 flowocr)。没有它或没有 cv2 就返回 None,
|
|
54
|
+
客户端照旧自己做前处理、送张量(握手里的 `pre` 报这件事)。"""
|
|
55
|
+
import importlib.util
|
|
56
|
+
from pathlib import Path
|
|
57
|
+
p = Path(__file__).with_name("recprep.py")
|
|
58
|
+
try:
|
|
59
|
+
import cv2 # noqa: F401
|
|
60
|
+
spec = importlib.util.spec_from_file_location("_flowocr_recprep", p)
|
|
61
|
+
mod = importlib.util.module_from_spec(spec)
|
|
62
|
+
spec.loader.exec_module(mod)
|
|
63
|
+
return mod
|
|
64
|
+
except Exception as exc: # noqa: BLE001
|
|
65
|
+
print(json.dumps({"note": f"前处理不在服务端做:{exc!r}"}), file=sys.stderr, flush=True)
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def gpu_initializers(path: str, device_id: int, keep: list) -> dict:
|
|
70
|
+
"""模型里的大权重(float32、≥1024 个元素)各拷一份到**我们自己 cudaMalloc 的**显存,按名字给出 OrtValue。
|
|
71
|
+
|
|
72
|
+
同一份 OrtValue 经 `SessionOptions.add_initializer` 交给同一个模型的每个 session,**权重在显存里只有一份**
|
|
73
|
+
(decode-buffer:每个 session 固定约 155 MB,其中 73 MB 是权重)。ORT 只收**用户持有**的缓冲
|
|
74
|
+
(它自己 `ortvalue_from_numpy(..., "cuda")` 分的会被拒),所以走 cudart + DLPack;
|
|
75
|
+
这些缓冲和进程同生共死(`keep` 握着,deleter 为空,进程退出时随 CUDA 上下文一起释放)。
|
|
76
|
+
没有 `onnx` 包(worker 起在别的解释器里)就返回空表、不共享,行为和以前一样。
|
|
77
|
+
"""
|
|
78
|
+
try:
|
|
79
|
+
import onnx
|
|
80
|
+
from onnx import numpy_helper
|
|
81
|
+
except ImportError:
|
|
82
|
+
print(json.dumps({"note": "没有 onnx 包,权重不共享"}), file=sys.stderr, flush=True)
|
|
83
|
+
return {}
|
|
84
|
+
import ctypes
|
|
85
|
+
|
|
86
|
+
class DLDevice(ctypes.Structure):
|
|
87
|
+
_fields_ = [("device_type", ctypes.c_int32), ("device_id", ctypes.c_int32)]
|
|
88
|
+
|
|
89
|
+
class DLDataType(ctypes.Structure):
|
|
90
|
+
_fields_ = [("code", ctypes.c_uint8), ("bits", ctypes.c_uint8), ("lanes", ctypes.c_uint16)]
|
|
91
|
+
|
|
92
|
+
class DLTensor(ctypes.Structure):
|
|
93
|
+
_fields_ = [("data", ctypes.c_void_p), ("device", DLDevice), ("ndim", ctypes.c_int32),
|
|
94
|
+
("dtype", DLDataType), ("shape", ctypes.POINTER(ctypes.c_int64)),
|
|
95
|
+
("strides", ctypes.POINTER(ctypes.c_int64)), ("byte_offset", ctypes.c_uint64)]
|
|
96
|
+
|
|
97
|
+
class DLManagedTensor(ctypes.Structure):
|
|
98
|
+
_fields_ = [("dl_tensor", DLTensor), ("manager_ctx", ctypes.c_void_p), ("deleter", ctypes.c_void_p)]
|
|
99
|
+
|
|
100
|
+
cudart = ctypes.CDLL("cudart64_13.dll" if sys.platform == "win32" else "libcudart.so.13")
|
|
101
|
+
if cudart.cudaSetDevice(device_id):
|
|
102
|
+
raise RuntimeError("cudaSetDevice 失败")
|
|
103
|
+
new_capsule = ctypes.pythonapi.PyCapsule_New
|
|
104
|
+
new_capsule.restype, new_capsule.argtypes = ctypes.py_object, [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_void_p]
|
|
105
|
+
out = {}
|
|
106
|
+
for t in onnx.load(path).graph.initializer:
|
|
107
|
+
arr = numpy_helper.to_array(t)
|
|
108
|
+
if arr.dtype != np.float32 or arr.size < 1024:
|
|
109
|
+
continue
|
|
110
|
+
arr = np.ascontiguousarray(arr)
|
|
111
|
+
ptr = ctypes.c_void_p()
|
|
112
|
+
if cudart.cudaMalloc(ctypes.byref(ptr), ctypes.c_size_t(arr.nbytes)) or \
|
|
113
|
+
cudart.cudaMemcpy(ptr, arr.ctypes.data_as(ctypes.c_void_p), ctypes.c_size_t(arr.nbytes), 1): # 1 = 主机到设备
|
|
114
|
+
raise RuntimeError(f"权重 {t.name} 拷不上显存")
|
|
115
|
+
shape = (ctypes.c_int64 * arr.ndim)(*arr.shape)
|
|
116
|
+
mt = DLManagedTensor(DLTensor(ptr, DLDevice(2, device_id), arr.ndim, DLDataType(2, 32, 1), shape, None, 0), None, None)
|
|
117
|
+
cap = new_capsule(ctypes.addressof(mt), b"dltensor", None) # 2 = kDLCUDA;DLDataType(2, 32, 1) = float32
|
|
118
|
+
val = ort.OrtValue(ort.capi._pybind_state.OrtValue.from_dlpack(cap, False))
|
|
119
|
+
keep += [ptr, shape, mt, cap, val]
|
|
120
|
+
out[t.name] = val
|
|
121
|
+
return out
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
GRAPH_CAPTURE_RUNS = 4
|
|
125
|
+
"""建 CUDA Graph session 时连跑几次 `run_with_iobinding`:ORT 的 CUDA EP 要先有几次常规推理才捕图(本机 ORT 1.29 上跑 1 次不够),
|
|
126
|
+
多跑一两次宁多勿少——每个形状只付一次,每次几毫秒。"""
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
GRAPH_EXCLUSIVE_REPLAYS = 8
|
|
130
|
+
"""图 session 建好之后的前几次回放仍拿**独占**闸,之后才共享。经验阈值(2026-09-26,ORT 1.29):建图时连跑 4 次、甚至 12 次之后
|
|
131
|
+
直接共享回放照崩(两段 × 三遍全崩),前 2 次独占也崩,前 8 次独占两段 × 三遍全过——真正的捕获落在建好之后头几次调用里,
|
|
132
|
+
和建图时跑几次无关,原理没弄清。只在开 `--ort-cuda-graph` 时起作用。"""
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class _CaptureGate:
|
|
136
|
+
"""读写闸:很多个 `shared()` 可以同时持有,`exclusive()` 独占——等在途的共享的都退出才进,
|
|
137
|
+
**排上队之后新来的共享请求先等**(写优先:服务里两条连接的请求源源不断,不然捕图会被饿死)。CUDA Graph 捕图用(见 main 里 `gate`)。"""
|
|
138
|
+
|
|
139
|
+
def __init__(self) -> None:
|
|
140
|
+
self._cv = threading.Condition()
|
|
141
|
+
self._readers = 0
|
|
142
|
+
self._writing = False
|
|
143
|
+
self._writers_waiting = 0
|
|
144
|
+
|
|
145
|
+
@contextlib.contextmanager
|
|
146
|
+
def shared(self):
|
|
147
|
+
with self._cv:
|
|
148
|
+
while self._writing or self._writers_waiting:
|
|
149
|
+
self._cv.wait()
|
|
150
|
+
self._readers += 1
|
|
151
|
+
try:
|
|
152
|
+
yield
|
|
153
|
+
finally:
|
|
154
|
+
with self._cv:
|
|
155
|
+
self._readers -= 1
|
|
156
|
+
if not self._readers:
|
|
157
|
+
self._cv.notify_all()
|
|
158
|
+
|
|
159
|
+
@contextlib.contextmanager
|
|
160
|
+
def exclusive(self):
|
|
161
|
+
with self._cv:
|
|
162
|
+
self._writers_waiting += 1
|
|
163
|
+
while self._writing or self._readers:
|
|
164
|
+
self._cv.wait()
|
|
165
|
+
self._writers_waiting -= 1
|
|
166
|
+
self._writing = True
|
|
167
|
+
try:
|
|
168
|
+
yield
|
|
169
|
+
finally:
|
|
170
|
+
with self._cv:
|
|
171
|
+
self._writing = False
|
|
172
|
+
self._cv.notify_all()
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def serve_socket(a, hello: dict, infer, background: bool = False) -> int:
|
|
176
|
+
"""在 127.0.0.1 上等 N 个 worker 接进来,**共用一份模型和显存**。
|
|
177
|
+
|
|
178
|
+
`background=True`(管道模式的 `--side-listen`):accept 循环放后台线程、返回端口,由调用方写进自己的 ready 行;
|
|
179
|
+
进程的生死仍由管道那条决定(stdin 读到 bye / EOF 主循环退出,后台线程是 daemon)。
|
|
180
|
+
|
|
181
|
+
每个客户端一个线程;锁在 `infer` 里(2026-09-23 起):只有池子的账一把(CUDA Graph session 另各一把),
|
|
182
|
+
请求可以并发、同形状也行(原来一把全局锁,多 worker 的请求排成一队,decode-buffer)。客户端各带自己的共享内存(hello 里给名字和输入区大小),
|
|
183
|
+
所以**张量不经过 socket**——socket 上只走一行 JSON。
|
|
184
|
+
|
|
185
|
+
**握手要核能力**(2026-09-20 审计七):客户端在 hello 里报 `want`(它要哪几个模型),
|
|
186
|
+
服务端核对之后回 `{"ok":1,"models":[…]}`,少哪个就回 `{"ok":0,"missing":[…]}` 让它**自己去 spawn**。
|
|
187
|
+
以前不核:`FLOWOCR_ORT_ADDR` 是进程级的,`OrtClient` 见到就接上去而不看自己要什么,
|
|
188
|
+
于是 `--workers N` 时 det 的请求会打进只加载了 rec 的服务里。
|
|
189
|
+
|
|
190
|
+
**父进程没了就退出**(`--exit-on-stdin-eof`):这个进程握着显存,而
|
|
191
|
+
`while True: accept()` 自己不会停——Ctrl-C 或驱动中途异常时会留一个吃着 GPU 的孤儿。
|
|
192
|
+
|
|
193
|
+
⚠ **判据是"stdin 读到 EOF",不是"拿 pid 探活"**(2026-09-20 实测):
|
|
194
|
+
第一版写的是每 2 秒 `os.kill(parent_pid, 0)`,冒烟直接不过——
|
|
195
|
+
Windows 上只要**还有谁握着那个进程的句柄**(父进程自己的 `Popen` 对象就算),
|
|
196
|
+
进程都退出了 `OpenProcess` 照样成功、一个异常都不抛,于是看门狗永远不响。
|
|
197
|
+
(顺带:signal 0 在 Windows 上确实只探活、不会误杀,这点验过。)
|
|
198
|
+
stdin 那条没有这个问题:管道的写端跟着父进程的句柄表一起关,**EOF 是内核给的**。
|
|
199
|
+
调用方要 `stdin=subprocess.PIPE` 并且一直不关它。
|
|
200
|
+
"""
|
|
201
|
+
import os
|
|
202
|
+
import socket
|
|
203
|
+
import threading
|
|
204
|
+
|
|
205
|
+
served = sorted(hello.get("models") or ())
|
|
206
|
+
|
|
207
|
+
def watch_stdin() -> None:
|
|
208
|
+
try:
|
|
209
|
+
sys.stdin.buffer.read() # 父进程活着就一直阻塞在这
|
|
210
|
+
except Exception:
|
|
211
|
+
pass
|
|
212
|
+
os._exit(0) # 握着 CUDA 上下文,别走正常析构
|
|
213
|
+
|
|
214
|
+
if a.exit_on_stdin_eof and not background: # 旁路模式下 stdin 就是管道本身,由主循环读
|
|
215
|
+
threading.Thread(target=watch_stdin, daemon=True).start()
|
|
216
|
+
srv = socket.socket()
|
|
217
|
+
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
218
|
+
srv.bind(("127.0.0.1", max(a.listen, 0)))
|
|
219
|
+
srv.listen(16)
|
|
220
|
+
port = srv.getsockname()[1]
|
|
221
|
+
|
|
222
|
+
def client(conn: "socket.socket") -> None:
|
|
223
|
+
f = conn.makefile("rwb")
|
|
224
|
+
shm = None
|
|
225
|
+
try:
|
|
226
|
+
h = json.loads(f.readline() or "{}").get("hello") or {}
|
|
227
|
+
missing = [m for m in (h.get("want") or ()) if m not in served]
|
|
228
|
+
try: # 卡号也要对上:gpu:1 的客户端不能接到 0 号卡的服务上
|
|
229
|
+
wrong_dev = bool(h.get("device")) and norm_device(h["device"]) != hello.get("device")
|
|
230
|
+
except SystemExit:
|
|
231
|
+
wrong_dev = True
|
|
232
|
+
if missing or wrong_dev:
|
|
233
|
+
# **不服务它**:客户端会退回自己 spawn 一个带全套模型、对的设备的 worker
|
|
234
|
+
f.write((json.dumps({"ok": 0, "missing": missing, "models": served,
|
|
235
|
+
"device": hello.get("device")}) + "\n").encode())
|
|
236
|
+
f.flush()
|
|
237
|
+
return
|
|
238
|
+
shm = shared_memory.SharedMemory(name=h["shm"])
|
|
239
|
+
buf, in_bytes = shm.buf, int(h["in_bytes"])
|
|
240
|
+
f.write((json.dumps({"ok": 1, "models": served, "provider": hello.get("provider"), "pre": hello.get("pre"),
|
|
241
|
+
"device": hello.get("device"), "ort_version": hello.get("ort_version")}) + "\n").encode())
|
|
242
|
+
f.flush()
|
|
243
|
+
while True:
|
|
244
|
+
line = f.readline()
|
|
245
|
+
if not line:
|
|
246
|
+
break
|
|
247
|
+
req = json.loads(line)
|
|
248
|
+
if req.get("bye"):
|
|
249
|
+
break
|
|
250
|
+
rep = infer(req, buf, in_bytes) # 锁在 infer 里面:池子的账一把、每个 session 一把(不再一把全局锁)
|
|
251
|
+
f.write((json.dumps(rep) + "\n").encode())
|
|
252
|
+
f.flush()
|
|
253
|
+
except Exception as e: # 一个客户端崩了不该带走服务
|
|
254
|
+
try:
|
|
255
|
+
f.write((json.dumps({"error": repr(e)}) + "\n").encode())
|
|
256
|
+
f.flush()
|
|
257
|
+
except Exception:
|
|
258
|
+
pass
|
|
259
|
+
finally:
|
|
260
|
+
if shm is not None:
|
|
261
|
+
shm.close()
|
|
262
|
+
conn.close()
|
|
263
|
+
|
|
264
|
+
def accept_loop() -> None:
|
|
265
|
+
while True:
|
|
266
|
+
conn, _ = srv.accept()
|
|
267
|
+
# **关 Nagle**(2026-09-22,decode-buffer):每次往返是"一行很短的 JSON 过去、一行很短的 JSON 回来",
|
|
268
|
+
# 正是 Nagle + 延迟确认最伤的形状(共享服务和管道模式的旁路连接都走这里)
|
|
269
|
+
conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
|
270
|
+
threading.Thread(target=client, args=(conn,), daemon=True).start()
|
|
271
|
+
|
|
272
|
+
if background:
|
|
273
|
+
threading.Thread(target=accept_loop, daemon=True, name="side-listen").start()
|
|
274
|
+
return port
|
|
275
|
+
print(json.dumps({**hello, "port": port}), flush=True)
|
|
276
|
+
accept_loop()
|
|
277
|
+
return 0
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def main() -> int:
|
|
281
|
+
ap = argparse.ArgumentParser()
|
|
282
|
+
ap.add_argument("--model", action="append", required=True, metavar="名字=路径")
|
|
283
|
+
ap.add_argument("--shm", default="", help="共享内存的名字(调用方建、调用方销毁);"
|
|
284
|
+
"`--listen` 模式下由每个客户端在 hello 里给")
|
|
285
|
+
ap.add_argument("--in-bytes", type=int, default=0, help="输入区字节数(输出从这之后开始)")
|
|
286
|
+
ap.add_argument("--listen", type=int, default=-1, metavar="PORT",
|
|
287
|
+
help="**多客户端模式**:在 127.0.0.1 的这个端口上等(0 = 随便挑一个,"
|
|
288
|
+
"挑到的端口写在 ready 那行里)。N 个 worker 共用一个服务进程 = "
|
|
289
|
+
"**显存只有一份**——这是 `--workers` 能重新放宽的前提(owner 09-19)")
|
|
290
|
+
ap.add_argument("--exit-on-stdin-eof", action="store_true",
|
|
291
|
+
help="`--listen` 模式下**读到 stdin 的 EOF 就自己退出**——"
|
|
292
|
+
"调用方要 `stdin=subprocess.PIPE` 并一直不关它,父进程一没,管道写端就关。"
|
|
293
|
+
"服务进程握着显存而 accept 循环自己不会停,不盯着就会留孤儿(审计七)。"
|
|
294
|
+
"⚠ **别改回拿 pid 探活**:Windows 上只要还有谁握着句柄,"
|
|
295
|
+
"进程退出了 `os.kill(pid, 0)` 照样成功——第一版就是这么挂的。"
|
|
296
|
+
"⚠ 也**别在 stdin 是 DEVNULL 时给这个参数**:那会立刻读到 EOF、当场退出")
|
|
297
|
+
ap.add_argument("--device", default="gpu:0",
|
|
298
|
+
help="gpu:N = 必须用 N 号卡的 CUDA EP(这份 onnxruntime 没有 CUDA EP、或建 session 回落到 CPU,都退出码 3,不静默跑 CPU);"
|
|
299
|
+
"`gpu` 等于 `gpu:0`;cpu = 只用 CPUExecutionProvider(NVIDIA 包里也是)。"
|
|
300
|
+
"由调用方按 run_ocr2 的 --device 传(Codex 审计 P1;卡号贯通是复审 P2:原来 gpu:1 到这里就成了 0 号卡)")
|
|
301
|
+
ap.add_argument("--side-listen", action="store_true",
|
|
302
|
+
help="管道模式下**另开一个 127.0.0.1 端口**(写在 ready 行的 `port` 里),让同一个调用方再接几条连接进来、"
|
|
303
|
+
"同时有几个请求在途(`run_ocr2 --rec-inflight`,decode-buffer)。连接的握手和共享服务一样")
|
|
304
|
+
ap.add_argument("--per-shape", type=int, default=1, help="1 = 每个形状一个 session(见文件头)")
|
|
305
|
+
ap.add_argument("--share-mem", type=int, default=0,
|
|
306
|
+
help="1 = 同一个模型的 session 之间**共用显存**:①所有 session 共用一个进程级 CUDA arena"
|
|
307
|
+
"(`session.use_env_allocators`;默认每个 session 各自一份、各自涨到自己形状的激活峰值),"
|
|
308
|
+
"②权重在显存里只放一份(`gpu_initializers`)。15 个形状 4.8 GB → 1.5 GB、输出逐位相同(decode-buffer)。"
|
|
309
|
+
"`--gpu-mem-mb` 在这里才真是 arena 的总上限")
|
|
310
|
+
ap.add_argument("--cuda-graph-batch", type=int, default=0,
|
|
311
|
+
help="批 ≤ 这个数、宽 ≤ --cuda-graph-max-w 的形状用 **CUDA Graph** 回放(0 = 关)。小批次的推理是 kernel 启动受限的:"
|
|
312
|
+
"单独量批 1 × 320 宽 4.2 -> 2.2 ms、输出逐位相同(decode-buffer)。图 session 用**自己的** arena"
|
|
313
|
+
"(和共用 arena 放在一起会踩显存),所以只给激活小的形状")
|
|
314
|
+
ap.add_argument("--cuda-graph-max-w", type=int, default=960, help="见 --cuda-graph-batch")
|
|
315
|
+
ap.add_argument("--gpu-mem-mb", type=int, default=6144,
|
|
316
|
+
help="CUDA EP 的显存上限(MB,0 = 不限)。**owner 2026-09-19 定的配额是整机 8 GB**,"
|
|
317
|
+
"这里留 6 GB 给推理、剩下给桌面和别的负载。**超了 ORT 会当场报 OOM,而不是把卡吃光**——"
|
|
318
|
+
"那正是要的:每形状一个 session 时显存是按形状数线性涨的,探针一不小心就 24 个 session。")
|
|
319
|
+
ap.add_argument("--warm", default="",
|
|
320
|
+
help="起动后在后台预热的形状:`模型=批x宽,批x宽,…`(rec 的由管线按 recort.warm_shapes 生成)。每个新形状第一次要建 session 约 0.1 s + 首次 run 0.04~0.4 s,一趟 5 分钟段 18~26 个形状 = 4~6 s 压在 rec 的关键路径上;预热放在管线自己起动(建 det、探视频)的那几秒里做。同一个模型、同一份输入形状,不改读数")
|
|
321
|
+
ap.add_argument("--threads", type=int, default=0,
|
|
322
|
+
help="每个 session 的 intra-op 线程数(0 = ORT 默认:物理核数)。CPU 路径上 det 和 rec 的 session 同时在跑、"
|
|
323
|
+
"各自一池物理核数的线程会互相挤(2026-09-25 量的)")
|
|
324
|
+
ap.add_argument("--spin", type=int, default=1,
|
|
325
|
+
help="0 = 关掉 intra-op 线程池的自旋等待(`session.intra_op.allow_spinning`)。CPU 路径上几个 session 的线程池"
|
|
326
|
+
"轮空时自旋会把管线进程的 Python 线程挤饿(主线程提交段 4 → 16 s,2026-09-25)")
|
|
327
|
+
ap.add_argument("--max-sessions", type=int, default=12,
|
|
328
|
+
help="per-shape 池子里最多留几个 session(0 = 不限)。超了**按最久没用的淘汰**:"
|
|
329
|
+
"形状多的时候(探针扫宽 × 批)池子会线性吃显存,实测 24 个 session 吃到 15 GB。")
|
|
330
|
+
ap.add_argument("--cudnn-conv-search", default="EXHAUSTIVE", choices=("EXHAUSTIVE", "HEURISTIC", "DEFAULT"),
|
|
331
|
+
help="CUDA EP 的 `cudnn_conv_algo_search`,默认同 ORT 自己的默认(EXHAUSTIVE)。⚠ **别拿 DEFAULT 当修法**"
|
|
332
|
+
"(2026-09-22 nightly 踩的):ORT 1.26(CUDA 12 构建)+ cuDNN 9.9 在 sm_120(RTX 50 系)上,"
|
|
333
|
+
"EXHAUSTIVE / HEURISTIC 对 **fp16** 卷积报 `No valid engine configs for ConvFwd`;DEFAULT 能跑,"
|
|
334
|
+
"但它挑的是慢算法——rec 一批 360 ms,EXHAUSTIVE / HEURISTIC 是 15 ms,quick 六段墙钟 10 倍。"
|
|
335
|
+
"1.26 上 fp16 模型在这张卡上就是不可用,fp32 模型三种都正常;ORT 1.29(CUDA 13 构建)全都正常。")
|
|
336
|
+
a = ap.parse_args()
|
|
337
|
+
|
|
338
|
+
if hasattr(ort, "preload_dlls"):
|
|
339
|
+
ort.preload_dlls() # 不调它 CUDA EP 的 DLL 找不到、建 session 直接失败(CPU 包上是空操作)
|
|
340
|
+
models = dict(m.split("=", 1) for m in a.model)
|
|
341
|
+
# **设备由调用方说了算**(2026-09-22,Codex 审计 P1):原来按"这份 onnxruntime 有没有 CUDA EP"自己挑,
|
|
342
|
+
# 于是 NVIDIA 包里 `run_ocr2 --device cpu` 时 rec 照样上 GPU;CPU 包里要 GPU 却静默跑 CPU。
|
|
343
|
+
# 现在:gpu 就必须用上 CUDA EP(没有 / 回落都退出码 3),cpu 就只给 CPU EP;hello 里报出实际用的
|
|
344
|
+
a.device = norm_device(a.device)
|
|
345
|
+
has_cuda = "CUDAExecutionProvider" in ort.get_available_providers()
|
|
346
|
+
cuda_ok = a.device != "cpu"
|
|
347
|
+
if cuda_ok and not has_cuda:
|
|
348
|
+
print(json.dumps({"error": f"要 GPU,但这份 onnxruntime({ort.__version__})没有 CUDAExecutionProvider"
|
|
349
|
+
f"(有的是 {ort.get_available_providers()});CPU 包请给 --device cpu"}), flush=True)
|
|
350
|
+
return 3
|
|
351
|
+
# 显存配额(owner 2026-09-19):CUDA EP 的 arena 上限。给 0 就是老行为(不限)
|
|
352
|
+
cuda_opts = {"cudnn_conv_algo_search": a.cudnn_conv_search}
|
|
353
|
+
if cuda_ok:
|
|
354
|
+
cuda_opts["device_id"] = int(a.device.split(":")[1]) # 卡号跟着 --device 走(多卡机器上别让 ORT 默认落 0 号卡)
|
|
355
|
+
if a.gpu_mem_mb:
|
|
356
|
+
cuda_opts["gpu_mem_limit"] = a.gpu_mem_mb * 1024 * 1024
|
|
357
|
+
prov_opts = [("CUDAExecutionProvider", cuda_opts), "CPUExecutionProvider"] if cuda_ok else ["CPUExecutionProvider"]
|
|
358
|
+
|
|
359
|
+
share = bool(a.share_mem) and cuda_ok
|
|
360
|
+
if share:
|
|
361
|
+
mi = ort.OrtMemoryInfo("Cuda", ort.OrtAllocatorType.ORT_ARENA_ALLOCATOR, cuda_opts["device_id"], ort.OrtMemType.DEFAULT)
|
|
362
|
+
arena = ort.OrtArenaCfg({"max_mem": a.gpu_mem_mb * 1024 * 1024} if a.gpu_mem_mb else {})
|
|
363
|
+
ort.create_and_register_allocator_v2("CUDAExecutionProvider", mi,
|
|
364
|
+
{k: str(v) for k, v in cuda_opts.items() if k != "gpu_mem_limit"}, arena)
|
|
365
|
+
keep: list = []
|
|
366
|
+
inits: dict = {} # 模型路径 -> 共享的显存权重(普通 session 和 CUDA Graph session 共用一份)
|
|
367
|
+
opts: dict = {}
|
|
368
|
+
|
|
369
|
+
def options(path: str, graph: bool = False):
|
|
370
|
+
"""每个模型一份 SessionOptions(共享的权重按名字挂上去,不能串到别的模型)。
|
|
371
|
+
`graph`:CUDA Graph session 用的那份——**不接共用 arena**(图录的是固定显存地址,共用 arena 会把它用过的块分给别的 session:
|
|
372
|
+
交替回放 200 次类别错 81 次;各自 arena 0 次,decode-buffer),权重照旧共享。"""
|
|
373
|
+
if (path, graph) not in opts:
|
|
374
|
+
so = ort.SessionOptions()
|
|
375
|
+
if a.threads > 0:
|
|
376
|
+
so.intra_op_num_threads = a.threads
|
|
377
|
+
if not a.spin:
|
|
378
|
+
so.add_session_config_entry("session.intra_op.allow_spinning", "0")
|
|
379
|
+
if share:
|
|
380
|
+
if not graph:
|
|
381
|
+
so.add_session_config_entry("session.use_env_allocators", "1")
|
|
382
|
+
if path not in inits:
|
|
383
|
+
inits[path] = gpu_initializers(path, cuda_opts["device_id"], keep)
|
|
384
|
+
for name, val in inits[path].items():
|
|
385
|
+
so.add_initializer(name, val)
|
|
386
|
+
opts[(path, graph)] = so
|
|
387
|
+
return opts[(path, graph)]
|
|
388
|
+
|
|
389
|
+
def make(path: str, graph: bool = False):
|
|
390
|
+
prov = ([("CUDAExecutionProvider", {**cuda_opts, "enable_cuda_graph": "1"}), "CPUExecutionProvider"]
|
|
391
|
+
if graph else prov_opts)
|
|
392
|
+
s = ort.InferenceSession(path, sess_options=options(path, graph), providers=prov)
|
|
393
|
+
if cuda_ok:
|
|
394
|
+
# ORT 的 python 封装在**第一次 run 抛错时会把 session 重建成 CPU 的再试一次**(`_fallback_providers`)——
|
|
395
|
+
# 和上面"有 CUDA EP 却没用上就退出码 3"是同一条规矩:错就报错,不静默换后端
|
|
396
|
+
s.disable_fallback()
|
|
397
|
+
return s
|
|
398
|
+
|
|
399
|
+
base = {}
|
|
400
|
+
prov_used = ""
|
|
401
|
+
for name, path in models.items():
|
|
402
|
+
s = make(path)
|
|
403
|
+
prov = s.get_providers()[0]
|
|
404
|
+
if cuda_ok and "CUDA" not in prov:
|
|
405
|
+
print(json.dumps({"error": f"{name} 的 provider 回落到 {prov}(这份 onnxruntime 有 CUDA EP 却没用上)"}), flush=True)
|
|
406
|
+
return 3
|
|
407
|
+
prov_used = prov
|
|
408
|
+
base[name] = s
|
|
409
|
+
pool: dict = {} # (模型, 形状) -> (session, 推理锁:只有 CUDA Graph session 有,普通的是 nullcontext)
|
|
410
|
+
pool_lock = threading.Lock()
|
|
411
|
+
n_built = n_evicted = 0
|
|
412
|
+
build_sec = 0.0
|
|
413
|
+
rp = load_recprep()
|
|
414
|
+
hello = {"ready": 1, "provider": prov_used, "device": a.device, "ort_version": ort.__version__,
|
|
415
|
+
"models": {n: [i.name for i in s.get_inputs()] for n, s in base.items()}, "pre": rp is not None}
|
|
416
|
+
|
|
417
|
+
class Graph:
|
|
418
|
+
"""一个形状的 CUDA Graph session:输入输出绑在**固定地址**的显存 OrtValue 上,每次只拷新输入进去再回放。"""
|
|
419
|
+
|
|
420
|
+
def __init__(self, name: str, x: np.ndarray) -> None:
|
|
421
|
+
ref = base[name].run(None, {base[name].get_inputs()[0].name: x}) # 拿输出形状(动态 session 跑一次)
|
|
422
|
+
self.sess = make(models[name], graph=True)
|
|
423
|
+
self.xin = ort.OrtValue.ortvalue_from_numpy(x, "cuda", cuda_opts["device_id"])
|
|
424
|
+
self.outv = [ort.OrtValue.ortvalue_from_shape_and_type(list(r.shape), r.dtype, "cuda", cuda_opts["device_id"])
|
|
425
|
+
for r in ref]
|
|
426
|
+
self.ob = self.sess.io_binding()
|
|
427
|
+
self.ob.bind_ortvalue_input(self.sess.get_inputs()[0].name, self.xin)
|
|
428
|
+
for o, v in zip(self.sess.get_outputs(), self.outv):
|
|
429
|
+
self.ob.bind_ortvalue_output(o.name, v)
|
|
430
|
+
# 捕获**不在第一次 run**:ORT 的 CUDA EP 先跑几次常规推理(预热)才真正捕图。只跑一次的话,捕获会落到之后某次
|
|
431
|
+
# "回放"里——那时只拿着共享闸,撞上别的并发推理就是 `operation not permitted when stream is capturing`(2026-09-26 查出)。
|
|
432
|
+
# 所以建图的时候(持独占闸)多跑几次,捕完再交出去,之后的调用都是纯回放
|
|
433
|
+
for _ in range(GRAPH_CAPTURE_RUNS):
|
|
434
|
+
self.sess.run_with_iobinding(self.ob)
|
|
435
|
+
|
|
436
|
+
n_replays = 0
|
|
437
|
+
|
|
438
|
+
def replay_gate(self):
|
|
439
|
+
"""回放拿哪道闸:前 `GRAPH_EXCLUSIVE_REPLAYS` 次独占(捕获可能还没真正发生),之后共享。"""
|
|
440
|
+
self.n_replays += 1
|
|
441
|
+
return gate.exclusive() if self.n_replays <= GRAPH_EXCLUSIVE_REPLAYS else gate.shared()
|
|
442
|
+
|
|
443
|
+
def run(self, x: np.ndarray) -> list[np.ndarray]:
|
|
444
|
+
self.xin.update_inplace(x)
|
|
445
|
+
self.sess.run_with_iobinding(self.ob)
|
|
446
|
+
return [v.numpy() for v in self.outv]
|
|
447
|
+
|
|
448
|
+
def graph_ok(shape) -> bool:
|
|
449
|
+
return bool(cuda_ok and a.per_shape and a.cuda_graph_batch and shape[0] <= a.cuda_graph_batch
|
|
450
|
+
and shape[-1] <= a.cuda_graph_max_w)
|
|
451
|
+
|
|
452
|
+
# **捕图要独占这个进程的 CUDA**:ORT 捕图用全局捕获模式,捕的时候别的线程碰 CUDA(别的 session 推理、建 session、预热)
|
|
453
|
+
# 就是 `operation not permitted when stream is capturing`。2026-09-25 以为只是 D1(det 和图 session 同服务),
|
|
454
|
+
# 09-26 det 单起服务后照样崩;再二分:只有同时关掉 `--ort-warm` 的预热线程、`--rec-inflight` 降到 1 才捕得成——病根是服务里的并发,不是 det。
|
|
455
|
+
# 有了下面这道闸,det 留在同一个服务里也捕得成(gi-s2 60 s 全默认 + `--ort-cuda-graph 4`,文本和框与默认臂相同),D1 不用拆。
|
|
456
|
+
# 批 ≤ N、宽 ≤ 960 的 det 形状也会被捕(形状判据不分模型)
|
|
457
|
+
# 所以开捕图时加一道读写闸:普通推理 / 建普通 session 拿共享的,建图和图 session 的**前 `GRAPH_EXCLUSIVE_REPLAYS` 次回放**拿独占的(建图时连跑 `GRAPH_CAPTURE_RUNS` 次,
|
|
458
|
+
# 可全默认配置下真正的捕获仍落在之后头几次回放里——回放一律共享照崩;一律独占能跑但墙钟 +10%~+16%,2026-09-26 实测),独占的排上队之后新的共享请求先等(不让在途请求把捕图饿死)。
|
|
459
|
+
# 丢掉的 session 也在闸里释放(见 `dead`)。没开捕图时闸是空操作,默认路径不多付一分钱
|
|
460
|
+
gate = _CaptureGate() if cuda_ok and a.per_shape and a.cuda_graph_batch else None
|
|
461
|
+
|
|
462
|
+
def shared():
|
|
463
|
+
return gate.shared() if gate is not None else contextlib.nullcontext()
|
|
464
|
+
|
|
465
|
+
def build(name: str, shape: tuple, x: np.ndarray):
|
|
466
|
+
"""这个形状的 session:图 session 在独占闸里捕,普通的在共享闸里建。"""
|
|
467
|
+
if graph_ok(shape):
|
|
468
|
+
with gate.exclusive():
|
|
469
|
+
return Graph(name, np.ascontiguousarray(x))
|
|
470
|
+
with shared():
|
|
471
|
+
return make(models[name])
|
|
472
|
+
|
|
473
|
+
# **丢掉的 session 也要在闸里释放**:析构里有 cudaFree(Graph 还有固定地址的输入输出),捕图时别的线程这么一放就是
|
|
474
|
+
# 捕图失败(901 `operation failed due to a previous error during capture`)。两个线程同时建同一个形状时后到的那份、
|
|
475
|
+
# 预热建完发现池里已经有了的那份、LRU 淘汰的那份——原来都在闸外随手丢,启动时预热线程和请求线程最容易撞上(只在开预热时崩就是这个)。
|
|
476
|
+
# 淘汰的先放进 `dead`,推理时在共享闸里清;本线程手上的引用在共享闸里置空(`with shared(): made = None`——
|
|
477
|
+
# 引用是调用方自己的局部变量,交给别的函数去 del 放不掉它)
|
|
478
|
+
dead: list = []
|
|
479
|
+
|
|
480
|
+
def reap() -> None:
|
|
481
|
+
"""(持共享闸时调)真正释放淘汰下来的 session。"""
|
|
482
|
+
while dead:
|
|
483
|
+
try:
|
|
484
|
+
dead.pop()
|
|
485
|
+
except IndexError:
|
|
486
|
+
break
|
|
487
|
+
n_warmed = 0
|
|
488
|
+
|
|
489
|
+
def insert(key, made) -> tuple:
|
|
490
|
+
"""(持 pool_lock)放进池子:满了先按 LRU 淘汰一个——**淘汰在插入时做**,不在建之前(建在锁外,建的时候池子可能已经变了)。"""
|
|
491
|
+
nonlocal n_evicted
|
|
492
|
+
if a.max_sessions and len(pool) >= a.max_sessions:
|
|
493
|
+
# dict 有序:最久没用的排在最前(正在别的线程里跑的那个由它自己握着引用,跑完在闸里放掉);淘汰的进 `dead`,在闸里释放
|
|
494
|
+
dead.append(pool.pop(next(iter(pool))))
|
|
495
|
+
n_evicted += 1
|
|
496
|
+
pool[key] = (made, threading.Lock() if isinstance(made, Graph) else contextlib.nullcontext())
|
|
497
|
+
return pool[key]
|
|
498
|
+
|
|
499
|
+
def get_session(name: str, shape: tuple, x: np.ndarray) -> tuple:
|
|
500
|
+
"""这个形状的 session:池子里有就用(挪到 LRU 末尾);没有就**在锁外建**、锁内双查再放(2026-09-24:原来建在锁里,
|
|
501
|
+
一个新形状的 0.1 s 会挡住另一个在途请求查池子)。两个线程同时建同一个形状时后到的那份丢掉。"""
|
|
502
|
+
nonlocal n_built, build_sec
|
|
503
|
+
key = (name, shape)
|
|
504
|
+
with pool_lock:
|
|
505
|
+
if key in pool:
|
|
506
|
+
pool[key] = pool.pop(key) # 用过就挪到末尾(LRU)
|
|
507
|
+
return pool[key]
|
|
508
|
+
t_b = time.perf_counter()
|
|
509
|
+
made = build(name, shape, x)
|
|
510
|
+
with pool_lock:
|
|
511
|
+
# **建了几个 / 淘汰了几次**(2026-09-19 复审第 2 条):只有 `build_sec` 分不开
|
|
512
|
+
# "每个新形状一次性的规划"(整片上摊薄)和"LRU 反复淘汰重建"(整片上持续付),
|
|
513
|
+
# 而这两种对整片的预测是相反的。`evicted > 0` 就是后者在发生。
|
|
514
|
+
build_sec += time.perf_counter() - t_b
|
|
515
|
+
n_built += 1
|
|
516
|
+
if key not in pool:
|
|
517
|
+
return insert(key, made)
|
|
518
|
+
pool[key] = pool.pop(key)
|
|
519
|
+
hit = pool[key]
|
|
520
|
+
with shared(): # 别的线程先建好放进去了:自己这份在闸里放掉(出了 pool_lock 再放)
|
|
521
|
+
made = None
|
|
522
|
+
return hit
|
|
523
|
+
|
|
524
|
+
def warm(spec: str) -> None:
|
|
525
|
+
"""后台预热(`--warm`):逐个形状建 session + 用零输入跑一次(cuDNN 选算法在第一次 run 里),放进池子。
|
|
526
|
+
池子里已经有的(真请求先到了)跳过;**只填空位、不淘汰**——预热不该把真在用的形状挤出去。出错只记一笔,不影响服务。"""
|
|
527
|
+
nonlocal n_warmed
|
|
528
|
+
try:
|
|
529
|
+
name, _, shapes = spec.partition("=")
|
|
530
|
+
if name not in models:
|
|
531
|
+
return
|
|
532
|
+
h = int(base[name].get_inputs()[0].shape[2]) if isinstance(base[name].get_inputs()[0].shape[2], int) else 48
|
|
533
|
+
for bw in shapes.split(","):
|
|
534
|
+
b, w = (int(v) for v in bw.lower().split("x"))
|
|
535
|
+
shape = (b, 3, h, w)
|
|
536
|
+
key = (name, shape)
|
|
537
|
+
with pool_lock:
|
|
538
|
+
if key in pool or (a.max_sessions and len(pool) >= a.max_sessions):
|
|
539
|
+
continue
|
|
540
|
+
x = np.zeros(shape, np.float32)
|
|
541
|
+
made = build(name, shape, x)
|
|
542
|
+
if not isinstance(made, Graph):
|
|
543
|
+
with shared():
|
|
544
|
+
made.run(None, {made.get_inputs()[0].name: x})
|
|
545
|
+
with pool_lock:
|
|
546
|
+
kept = key not in pool and not (a.max_sessions and len(pool) >= a.max_sessions)
|
|
547
|
+
if kept:
|
|
548
|
+
insert(key, made)
|
|
549
|
+
n_warmed += 1
|
|
550
|
+
if not kept:
|
|
551
|
+
with shared(): # 真请求先建好了:预热这份在闸里放掉
|
|
552
|
+
made = None
|
|
553
|
+
except Exception as exc: # noqa: BLE001
|
|
554
|
+
print(f"[ort_server] 预热中断:{exc!r}", file=sys.stderr, flush=True)
|
|
555
|
+
|
|
556
|
+
def infer(req: dict, buf, in_bytes: int) -> dict:
|
|
557
|
+
"""一次推理。**这段是两种模式共用的**——别抄第二份。"""
|
|
558
|
+
name = req["model"]
|
|
559
|
+
pre_ms = 0.0
|
|
560
|
+
if "crops" in req:
|
|
561
|
+
# **送的是原样的 uint8 裁剪**(2026-09-23,decode-buffer):前处理在这个进程里做,不抢管线进程的 GIL
|
|
562
|
+
t_p = time.perf_counter()
|
|
563
|
+
crops, off = [], 0
|
|
564
|
+
for h, w, c in req["crops"]:
|
|
565
|
+
nb = h * w * c
|
|
566
|
+
crops.append(np.ndarray((h, w, c), dtype=np.uint8, buffer=buf[off: off + nb]))
|
|
567
|
+
off += nb
|
|
568
|
+
x = rp.batch(crops, int(req["grid"]), int(req["batch"]))
|
|
569
|
+
pre_ms = (time.perf_counter() - t_p) * 1000
|
|
570
|
+
else:
|
|
571
|
+
n = int(np.prod(req["shape"]))
|
|
572
|
+
x = np.ndarray(tuple(req["shape"]), dtype=np.float32, buffer=buf[: n * 4])
|
|
573
|
+
shape = tuple(x.shape)
|
|
574
|
+
# **锁只罩着池子的账**(查 / 建 / LRU),推理本身不加锁(2026-09-23,decode-buffer):
|
|
575
|
+
# 原来共享服务一把全局锁罩住整次推理,三个 worker 的请求排成一队——wuwa-s2 上每段 rec 等 38~42 s、推理只有 11~14 s。
|
|
576
|
+
# ORT 的 `InferenceSession.run` 本身允许多线程同时调,**同一个形状两个在途**也能叠起来(进程内实测批 1~16 吞吐 +22%~+38%,
|
|
577
|
+
# 定文本的 argmax 逐位相同;`prob` 只在批 1 上差 ≤5e-4——批 1 的 kernel 串行两遍也会差,不是并发引起的)。
|
|
578
|
+
# 只有 **CUDA Graph** session 仍逐个串行:图回放用的是固定地址的输入 / 输出缓冲
|
|
579
|
+
if a.per_shape:
|
|
580
|
+
sess, run_lock = get_session(name, shape, x)
|
|
581
|
+
else:
|
|
582
|
+
sess, run_lock = base[name], contextlib.nullcontext()
|
|
583
|
+
with pool_lock:
|
|
584
|
+
counters = {"built": n_built, "evicted": n_evicted, "build_ms": build_sec * 1000, "warmed": n_warmed}
|
|
585
|
+
with run_lock, (sess.replay_gate() if isinstance(sess, Graph) else shared()):
|
|
586
|
+
t0 = time.perf_counter()
|
|
587
|
+
if isinstance(sess, Graph):
|
|
588
|
+
outs = sess.run(np.ascontiguousarray(x))
|
|
589
|
+
else:
|
|
590
|
+
outs = sess.run(None, {sess.get_inputs()[0].name: x})
|
|
591
|
+
dt = time.perf_counter() - t0
|
|
592
|
+
sess = None # 跑的时候被淘汰了的话,最后一个引用在闸里放掉
|
|
593
|
+
reap()
|
|
594
|
+
off = in_bytes
|
|
595
|
+
meta = []
|
|
596
|
+
for o in outs:
|
|
597
|
+
o = np.ascontiguousarray(o)
|
|
598
|
+
np.ndarray(o.shape, dtype=o.dtype, buffer=buf[off: off + o.nbytes])[...] = o
|
|
599
|
+
meta.append([o.dtype.str, list(o.shape)])
|
|
600
|
+
off += o.nbytes
|
|
601
|
+
return {"outs": meta, "infer_ms": dt * 1000, "shape": list(shape), "pre_ms": pre_ms, **counters}
|
|
602
|
+
|
|
603
|
+
if a.warm and a.per_shape:
|
|
604
|
+
for spec in a.warm.split(";"):
|
|
605
|
+
threading.Thread(target=warm, args=(spec,), daemon=True, name="warm").start()
|
|
606
|
+
if a.listen >= 0:
|
|
607
|
+
return serve_socket(a, hello, infer)
|
|
608
|
+
|
|
609
|
+
shm = shared_memory.SharedMemory(name=a.shm)
|
|
610
|
+
buf = shm.buf
|
|
611
|
+
if a.side_listen: # 管道之外再开旁路连接(同一个调用方的第 2..N 个在途请求)
|
|
612
|
+
hello = {**hello, "port": serve_socket(a, hello, infer, background=True)}
|
|
613
|
+
print(json.dumps(hello), flush=True)
|
|
614
|
+
|
|
615
|
+
for line in sys.stdin:
|
|
616
|
+
line = line.strip()
|
|
617
|
+
if not line:
|
|
618
|
+
continue
|
|
619
|
+
req = json.loads(line)
|
|
620
|
+
if req.get("bye"):
|
|
621
|
+
break
|
|
622
|
+
try:
|
|
623
|
+
rep_ = infer(req, buf, a.in_bytes)
|
|
624
|
+
except Exception as e: # noqa: BLE001 报给调用方(它会 raise),别带着 traceback 死在 DEVNULL 的 stderr 里
|
|
625
|
+
rep_ = {"error": repr(e)[:2000]}
|
|
626
|
+
print(json.dumps(rep_), flush=True)
|
|
627
|
+
shm.close()
|
|
628
|
+
return 0
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
if __name__ == "__main__":
|
|
632
|
+
raise SystemExit(main())
|