@camstack/addon-pipeline 1.1.24 → 1.1.26
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.
- package/dist/audio-analyzer/index.js +2 -2
- package/dist/audio-analyzer/index.mjs +2 -2
- package/dist/audio-codec-ffmpeg/index.js +1 -1
- package/dist/audio-codec-ffmpeg/index.mjs +1 -1
- package/dist/decoder-ffmpeg/index.js +84 -12
- package/dist/decoder-ffmpeg/index.mjs +84 -12
- package/dist/detection-pipeline/index.js +149 -27
- package/dist/detection-pipeline/index.mjs +149 -27
- package/dist/{dist-pd_-3C0T.mjs → dist-CgEP_0OL.mjs} +794 -16
- package/dist/{dist-CE3a05qT.js → dist-DAIlCdAx.js} +799 -15
- package/dist/frame-handle-plane-Dq20KtKL.mjs +636 -0
- package/dist/frame-handle-plane-DtTRX_0n.js +647 -0
- package/dist/hub-hostname-DAJXlOgV.js +54 -0
- package/dist/hub-hostname-cCknRYKj.mjs +49 -0
- package/dist/{model-download-service-C-IHWnXx-BnQ_awK4.js → model-download-service-C-IHWnXx-DxM2DSns.js} +1 -2
- package/dist/motion-wasm/index.js +1 -1
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/pipeline-runner/index.js +341 -17
- package/dist/pipeline-runner/index.mjs +341 -17
- package/dist/recorder/index.js +267 -65
- package/dist/recorder/index.mjs +267 -65
- package/dist/stream-broker/_stub.js +39 -39
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BVFAEkIB.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-5tQlh9h4.mjs} +2 -2
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CkOPfV8r.mjs +26 -0
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-BJK0-svt.mjs +26 -0
- package/dist/stream-broker/{hostInit-DHej3Ep9.mjs → hostInit-DyLqyJaS.mjs} +2 -2
- package/dist/stream-broker/index.js +7 -639
- package/dist/stream-broker/index.mjs +5 -637
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-BqXU75it.js → MaskShapeCanvas-DI4BY7W2-BDLNwJ_F.js} +1 -1
- package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-Ct2fpgCd.js → MotionZonesSettings-NcxxQN8r-CoLjNiUN.js} +1 -1
- package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-a9eFVPPl.js → PrivacyMaskSettings-APgPLF7p-DJE3OU-q.js} +1 -1
- package/embed-dist/assets/index-C-pL8ETk.js +81 -0
- package/embed-dist/assets/index-DrJ0ee3f.css +2 -0
- package/embed-dist/index.html +2 -2
- package/package.json +1 -1
- package/python/inference_pool.py +522 -27
- package/python/test_inference_pool_backpressure.py +121 -0
- package/python/test_inference_pool_coreml_cache.py +416 -0
- package/python/test_inference_pool_device_selection.py +256 -0
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-BFeBlYRQ.mjs +0 -26
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-BC1Jt6G8.mjs +0 -26
- package/embed-dist/assets/index-C7SSikPl.css +0 -2
- package/embed-dist/assets/index-DhdVxWXL.js +0 -80
- package/dist/{model-download-service-C-IHWnXx-3Mmeob3l.mjs → model-download-service-C-IHWnXx-BPy6aoAx.mjs} +1 -1
package/python/inference_pool.py
CHANGED
|
@@ -44,10 +44,13 @@ import concurrent.futures
|
|
|
44
44
|
import io
|
|
45
45
|
import json
|
|
46
46
|
import os
|
|
47
|
+
import platform
|
|
48
|
+
import shutil
|
|
47
49
|
import struct
|
|
48
50
|
import sys
|
|
49
51
|
import threading
|
|
50
52
|
import time
|
|
53
|
+
from collections import deque
|
|
51
54
|
from dataclasses import dataclass, field
|
|
52
55
|
from typing import Any, Awaitable, Callable, Optional
|
|
53
56
|
|
|
@@ -169,6 +172,274 @@ _ov_optimal_reqs: int = 0
|
|
|
169
172
|
# query the device's optimal request count). ≈ measured optimal on Intel
|
|
170
173
|
# CPU/iGPU/NPU (4-5). Threads idle-block on infer, so over-provisioning is cheap.
|
|
171
174
|
OV_DEFAULT_CONCURRENCY: int = 4
|
|
175
|
+
# OpenVINO device-mode decision — EXACT port of Scrypted's OpenVINO plugin
|
|
176
|
+
# device switch (scrypted-ov-init.py:109-194). Scrypted's rationale, kept
|
|
177
|
+
# verbatim: "AUTO mode can cause conflicts or hide errors with NPU and GPU
|
|
178
|
+
# so try to be explicit and fall back accordingly." The intel-iGPU-only case
|
|
179
|
+
# (the N100) compiles explicit "GPU" — the exact host where the AUTO plugin's
|
|
180
|
+
# mid-flight CPU-helper -> GPU handover invalidated in-flight InferRequest
|
|
181
|
+
# output ports ("Cannot find tensor for port opset1::Result score_8").
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _resolve_ov_mode(available: list[str], full_names: dict[str, str]) -> str:
|
|
185
|
+
"""Scrypted's exact mode string for an AUTO/default device request.
|
|
186
|
+
|
|
187
|
+
`available` is `core.available_devices`; `full_names` maps each device to
|
|
188
|
+
its FULL_DEVICE_NAME property (entries whose property query failed are
|
|
189
|
+
absent — mirrors Scrypted's per-device `except: pass`, so such a device
|
|
190
|
+
is not classified). The branch matrix, byte-faithful to Scrypted:
|
|
191
|
+
|
|
192
|
+
npu & gpu -> "AUTO:NPU,GPU,CPU"
|
|
193
|
+
npu & !gpu -> "AUTO:NPU,CPU"
|
|
194
|
+
NVIDIA dGPU(s) -> "AUTO:<dgpu[,dgpu...]>,CPU"
|
|
195
|
+
gpu (intel only) -> "GPU" (explicit — the N100 case)
|
|
196
|
+
else -> "AUTO"
|
|
197
|
+
"""
|
|
198
|
+
npu = False
|
|
199
|
+
gpu = False
|
|
200
|
+
dgpus: list[str] = []
|
|
201
|
+
for device in available:
|
|
202
|
+
full_device_name = full_names.get(device)
|
|
203
|
+
if full_device_name is None:
|
|
204
|
+
continue
|
|
205
|
+
if "NVIDIA" in full_device_name and "dGPU" in full_device_name:
|
|
206
|
+
dgpus.append(device)
|
|
207
|
+
if "NPU" in device:
|
|
208
|
+
npu = True
|
|
209
|
+
if "GPU" in device:
|
|
210
|
+
gpu = True
|
|
211
|
+
|
|
212
|
+
mode = "AUTO"
|
|
213
|
+
if npu:
|
|
214
|
+
if gpu:
|
|
215
|
+
mode = "AUTO:NPU,GPU,CPU"
|
|
216
|
+
else:
|
|
217
|
+
mode = "AUTO:NPU,CPU"
|
|
218
|
+
elif len(dgpus):
|
|
219
|
+
mode = f"AUTO:{','.join(dgpus)},CPU"
|
|
220
|
+
# forcing GPU can cause crashes on older GPU. (Scrypted's comment — the
|
|
221
|
+
# GPU-mode compile failure falls back to AUTO in the compile loop below.)
|
|
222
|
+
elif gpu:
|
|
223
|
+
mode = "GPU"
|
|
224
|
+
return mode
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ONNX Runtime execution-provider names used by _resolve_onnx_providers.
|
|
228
|
+
ONNX_CUDA_EP = "CUDAExecutionProvider"
|
|
229
|
+
ONNX_COREML_EP = "CoreMLExecutionProvider"
|
|
230
|
+
ONNX_CPU_EP = "CPUExecutionProvider"
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _resolve_onnx_providers(
|
|
234
|
+
device: str,
|
|
235
|
+
plat: str,
|
|
236
|
+
machine: str,
|
|
237
|
+
available: list[str],
|
|
238
|
+
cuda_device_id: int = 0,
|
|
239
|
+
) -> list:
|
|
240
|
+
"""Ordered ONNX Runtime providers — hardware first, CPU always last.
|
|
241
|
+
|
|
242
|
+
Mirrors Scrypted's ONNX plugin ordering (scrypted-onnx-init.py:95-109)
|
|
243
|
+
for the AUTO/default case:
|
|
244
|
+
|
|
245
|
+
darwin -> CoreMLExecutionProvider
|
|
246
|
+
linux/win on x86_64/AMD64 -> ("CUDAExecutionProvider", {"device_id": N})
|
|
247
|
+
always -> CPUExecutionProvider appended LAST
|
|
248
|
+
|
|
249
|
+
Deliberate adaptations for camstack:
|
|
250
|
+
- an operator device pin is honored: "cuda"/"coreml" put that EP first
|
|
251
|
+
(CPU still last as fallback); "cpu" compiles CPU only.
|
|
252
|
+
- hardware entries not present in `available`
|
|
253
|
+
(ort.get_available_providers()) are dropped — camstack ships the
|
|
254
|
+
plain `onnxruntime` wheel on most nodes, and requesting an EP the
|
|
255
|
+
build lacks raises at session creation instead of falling back.
|
|
256
|
+
- platform match uses startswith so "darwin" never matches "win"
|
|
257
|
+
(Scrypted's `"win" in sys.platform` would).
|
|
258
|
+
"""
|
|
259
|
+
dev = (device or "").strip().lower()
|
|
260
|
+
ordered: list = []
|
|
261
|
+
if dev == "cuda":
|
|
262
|
+
ordered.append((ONNX_CUDA_EP, {"device_id": cuda_device_id}))
|
|
263
|
+
elif dev == "coreml":
|
|
264
|
+
ordered.append(ONNX_COREML_EP)
|
|
265
|
+
elif dev == "cpu":
|
|
266
|
+
pass # explicit CPU pin — no hardware EPs
|
|
267
|
+
else:
|
|
268
|
+
# AUTO/default — Scrypted's platform-driven ordering.
|
|
269
|
+
if plat == "darwin":
|
|
270
|
+
ordered.append(ONNX_COREML_EP)
|
|
271
|
+
if plat.startswith(("linux", "win")) and machine in ("x86_64", "AMD64"):
|
|
272
|
+
ordered.append((ONNX_CUDA_EP, {"device_id": cuda_device_id}))
|
|
273
|
+
providers: list = [
|
|
274
|
+
p for p in ordered
|
|
275
|
+
if (p[0] if isinstance(p, tuple) else p) in available
|
|
276
|
+
]
|
|
277
|
+
providers.append(ONNX_CPU_EP)
|
|
278
|
+
return providers
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
# ---------------------------------------------------------------------------
|
|
282
|
+
# CoreML persistent compiled-model cache
|
|
283
|
+
# ---------------------------------------------------------------------------
|
|
284
|
+
# macOS bug (documented by Scrypted, coreml-init comment): the OS-level
|
|
285
|
+
# compiled-model cache is NOT reused across process restarts — every
|
|
286
|
+
# `ct.models.MLModel(.mlpackage)` load RECOMPILES the model, and the stale OS
|
|
287
|
+
# cache is only cleared on reboot. camstack loads the .mlpackage on every pool
|
|
288
|
+
# spawn, so each spawn paid the full recompile. Fix: after the first compile,
|
|
289
|
+
# copy the compiled `.mlmodelc` (get_compiled_model_path() returns a TEMP dir
|
|
290
|
+
# that dies with the MLModel object) into a persistent `.coreml-cache` dir next
|
|
291
|
+
# to the model (mirrors the OpenVINO `.ov-cache` convention); subsequent spawns
|
|
292
|
+
# load it via `ct.models.CompiledMLModel(...)` — no recompile. A stamp file
|
|
293
|
+
# fingerprinting the source model invalidates the cache when the model changes.
|
|
294
|
+
# HARD RULE: every failure in this machinery falls back to the plain
|
|
295
|
+
# `ct.models.MLModel(path)` load — exactly the pre-cache behavior.
|
|
296
|
+
|
|
297
|
+
COREML_CACHE_DIR_NAME = ".coreml-cache"
|
|
298
|
+
COREML_CACHE_STAMP_VERSION = 1
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _coreml_cache_paths(model_path: str) -> tuple[str, str]:
|
|
302
|
+
"""(compiled .mlmodelc cache dir, stamp file) for a source model path.
|
|
303
|
+
|
|
304
|
+
The cache lives in a dot-dir next to the model file — same convention as
|
|
305
|
+
the OpenVINO `.ov-cache` — so it ships/wipes with the models dir.
|
|
306
|
+
"""
|
|
307
|
+
models_dir = os.path.dirname(os.path.abspath(model_path))
|
|
308
|
+
stem = os.path.splitext(os.path.basename(model_path))[0]
|
|
309
|
+
cache_root = os.path.join(models_dir, COREML_CACHE_DIR_NAME)
|
|
310
|
+
return (
|
|
311
|
+
os.path.join(cache_root, stem + ".mlmodelc"),
|
|
312
|
+
os.path.join(cache_root, stem + ".stamp.json"),
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _coreml_model_fingerprint(model_path: str) -> dict:
|
|
317
|
+
"""Cheap source-model fingerprint — file count + total size + max mtime.
|
|
318
|
+
|
|
319
|
+
Works for both a plain file (.mlmodel) and a .mlpackage DIRECTORY (the
|
|
320
|
+
normal case), whose top-level mtime alone would not reflect content edits.
|
|
321
|
+
JSON-serializable so it round-trips through the stamp file unchanged.
|
|
322
|
+
"""
|
|
323
|
+
if not os.path.isdir(model_path):
|
|
324
|
+
st = os.stat(model_path)
|
|
325
|
+
return {"kind": "file", "files": 1, "size": st.st_size, "mtimeNs": st.st_mtime_ns}
|
|
326
|
+
total_size = 0
|
|
327
|
+
latest_mtime = 0
|
|
328
|
+
file_count = 0
|
|
329
|
+
for root, _dirs, files in os.walk(model_path):
|
|
330
|
+
for name in files:
|
|
331
|
+
st = os.stat(os.path.join(root, name))
|
|
332
|
+
total_size += st.st_size
|
|
333
|
+
if st.st_mtime_ns > latest_mtime:
|
|
334
|
+
latest_mtime = st.st_mtime_ns
|
|
335
|
+
file_count += 1
|
|
336
|
+
return {"kind": "dir", "files": file_count, "size": total_size, "mtimeNs": latest_mtime}
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _coreml_stamp_matches(stamp: Any, fingerprint: dict) -> bool:
|
|
340
|
+
"""Pure decision: does a parsed stamp validate the cached compile?"""
|
|
341
|
+
return (
|
|
342
|
+
isinstance(stamp, dict)
|
|
343
|
+
and stamp.get("version") == COREML_CACHE_STAMP_VERSION
|
|
344
|
+
and stamp.get("fingerprint") == fingerprint
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _persist_coreml_compiled(
|
|
349
|
+
compiled_src: str, cache_path: str, stamp_path: str, fingerprint: dict,
|
|
350
|
+
) -> None:
|
|
351
|
+
"""Copy coremltools' TEMP compiled dir into the persistent cache path.
|
|
352
|
+
|
|
353
|
+
Crash-safe ordering: copy into a tmp sibling on the SAME filesystem, then
|
|
354
|
+
`os.replace` into place, and write the stamp LAST — a stamp file only ever
|
|
355
|
+
exists for a fully-moved `.mlmodelc`, so a crash mid-copy can never
|
|
356
|
+
produce a half-written cache that validates. Raises on failure — the
|
|
357
|
+
caller logs and continues WITHOUT a cache (model already loaded fine).
|
|
358
|
+
"""
|
|
359
|
+
cache_root = os.path.dirname(cache_path)
|
|
360
|
+
os.makedirs(cache_root, exist_ok=True)
|
|
361
|
+
tmp_dir = f"{cache_path}.tmp-{os.getpid()}"
|
|
362
|
+
if os.path.exists(tmp_dir):
|
|
363
|
+
shutil.rmtree(tmp_dir)
|
|
364
|
+
try:
|
|
365
|
+
shutil.copytree(compiled_src, tmp_dir)
|
|
366
|
+
# Drop any stale previous cache: its stamp first (so nothing validates
|
|
367
|
+
# the old dir), then the dir itself — POSIX rename cannot replace a
|
|
368
|
+
# non-empty directory.
|
|
369
|
+
if os.path.exists(stamp_path):
|
|
370
|
+
os.remove(stamp_path)
|
|
371
|
+
if os.path.exists(cache_path):
|
|
372
|
+
shutil.rmtree(cache_path)
|
|
373
|
+
os.replace(tmp_dir, cache_path)
|
|
374
|
+
except BaseException:
|
|
375
|
+
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
376
|
+
raise
|
|
377
|
+
stamp_tmp = f"{stamp_path}.tmp-{os.getpid()}"
|
|
378
|
+
try:
|
|
379
|
+
with open(stamp_tmp, "w", encoding="utf-8") as fh:
|
|
380
|
+
json.dump({"version": COREML_CACHE_STAMP_VERSION, "fingerprint": fingerprint}, fh)
|
|
381
|
+
os.replace(stamp_tmp, stamp_path)
|
|
382
|
+
except BaseException:
|
|
383
|
+
try:
|
|
384
|
+
os.remove(stamp_tmp)
|
|
385
|
+
except OSError:
|
|
386
|
+
pass
|
|
387
|
+
raise
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _acquire_coreml_model(ct: Any, path: str, compute_units: Any) -> tuple[Any, Any]:
|
|
391
|
+
"""Return (model, spec) for the CoreML branch via the persistent cache.
|
|
392
|
+
|
|
393
|
+
Cache HIT → `CompiledMLModel(cache)` — loads the already-compiled
|
|
394
|
+
`.mlmodelc`, no recompile. It has NO `get_spec()`, so the
|
|
395
|
+
spec is read straight from the source .mlpackage with
|
|
396
|
+
`ct.utils.load_spec` (cheap — parses the protobuf, does not
|
|
397
|
+
compile). Spec-derived metadata is therefore identical to
|
|
398
|
+
what `MLModel.get_spec()` returned before.
|
|
399
|
+
Cache MISS → plain `MLModel(path)` (compiles — pre-cache behavior), then
|
|
400
|
+
best-effort persist of the compiled dir for the next spawn.
|
|
401
|
+
ANY failure in the cache machinery → the plain MLModel path, exactly as
|
|
402
|
+
before this cache existed. Never raises differently from pre-cache code.
|
|
403
|
+
"""
|
|
404
|
+
try:
|
|
405
|
+
cache_path, stamp_path = _coreml_cache_paths(path)
|
|
406
|
+
fingerprint = _coreml_model_fingerprint(path)
|
|
407
|
+
stamp: Any = None
|
|
408
|
+
if os.path.isfile(stamp_path):
|
|
409
|
+
with open(stamp_path, "r", encoding="utf-8") as fh:
|
|
410
|
+
stamp = json.load(fh)
|
|
411
|
+
if _coreml_stamp_matches(stamp, fingerprint) and os.path.isdir(cache_path):
|
|
412
|
+
spec = ct.utils.load_spec(path)
|
|
413
|
+
model = ct.models.CompiledMLModel(cache_path, compute_units)
|
|
414
|
+
sys.stderr.write(f"CoreML: compiled-model cache HIT {cache_path}\n")
|
|
415
|
+
sys.stderr.flush()
|
|
416
|
+
return model, spec
|
|
417
|
+
except Exception as exc:
|
|
418
|
+
sys.stderr.write(
|
|
419
|
+
f"CoreML: compiled-model cache lookup failed ({exc}); "
|
|
420
|
+
f"falling back to plain compile of {os.path.basename(path)}\n"
|
|
421
|
+
)
|
|
422
|
+
sys.stderr.flush()
|
|
423
|
+
|
|
424
|
+
# Cache MISS / invalid / lookup failure — identical to pre-cache behavior.
|
|
425
|
+
model = ct.models.MLModel(path, compute_units=compute_units)
|
|
426
|
+
spec = model.get_spec()
|
|
427
|
+
try:
|
|
428
|
+
cache_path, stamp_path = _coreml_cache_paths(path)
|
|
429
|
+
fingerprint = _coreml_model_fingerprint(path)
|
|
430
|
+
compiled_src = model.get_compiled_model_path()
|
|
431
|
+
_persist_coreml_compiled(compiled_src, cache_path, stamp_path, fingerprint)
|
|
432
|
+
sys.stderr.write(
|
|
433
|
+
f"CoreML: compiled-model cache MISS — compiled and persisted {cache_path}\n"
|
|
434
|
+
)
|
|
435
|
+
sys.stderr.flush()
|
|
436
|
+
except Exception as exc:
|
|
437
|
+
sys.stderr.write(
|
|
438
|
+
f"CoreML: could not persist compiled-model cache ({exc}); "
|
|
439
|
+
f"continuing without cache\n"
|
|
440
|
+
)
|
|
441
|
+
sys.stderr.flush()
|
|
442
|
+
return model, spec
|
|
172
443
|
|
|
173
444
|
|
|
174
445
|
def _init_runtime(runtime: str) -> None:
|
|
@@ -201,8 +472,28 @@ def _load_model(slot: ModelSlot, config: dict) -> None:
|
|
|
201
472
|
"ane": ct.ComputeUnit.CPU_AND_NE,
|
|
202
473
|
"all": ct.ComputeUnit.ALL,
|
|
203
474
|
}
|
|
204
|
-
|
|
205
|
-
|
|
475
|
+
# NOTE (Scrypted caveat, coreml-init comment): a macOS bug can cause
|
|
476
|
+
# the .mlpackage to be RECOMPILED on every load with the compiled
|
|
477
|
+
# cache not reused until reboot. camstack loads the .mlpackage on
|
|
478
|
+
# every pool spawn, so it was exposed to the same trap.
|
|
479
|
+
# _acquire_coreml_model routes through a persistent `.coreml-cache`
|
|
480
|
+
# of the compiled `.mlmodelc` (CompiledMLModel on hit, plain MLModel
|
|
481
|
+
# compile + persist on miss) and returns the spec read via
|
|
482
|
+
# ct.utils.load_spec on hits — falling back to the plain
|
|
483
|
+
# MLModel/get_spec() path on ANY cache failure.
|
|
484
|
+
model, spec = _acquire_coreml_model(
|
|
485
|
+
ct, path, compute_map.get(device, ct.ComputeUnit.ALL),
|
|
486
|
+
)
|
|
487
|
+
# Report the compute unit that was ACTUALLY applied so a silent
|
|
488
|
+
# CPU-only placement is visible in the pool stderr (mirrors the
|
|
489
|
+
# OpenVINO EXECUTION_DEVICES / ONNX provider reports). Both MLModel
|
|
490
|
+
# and CompiledMLModel expose `compute_unit`.
|
|
491
|
+
_actual_cu = getattr(model, "compute_unit", None)
|
|
492
|
+
sys.stderr.write(
|
|
493
|
+
f"CoreML: loaded {os.path.basename(path)} device={device} "
|
|
494
|
+
f"compute_unit={_actual_cu}\n"
|
|
495
|
+
)
|
|
496
|
+
sys.stderr.flush()
|
|
206
497
|
input_spec = spec.description.input[0]
|
|
207
498
|
slot.config["_input_name"] = input_spec.name
|
|
208
499
|
slot.config["_input_type"] = input_spec.type.WhichOneof("Type")
|
|
@@ -234,16 +525,29 @@ def _load_model(slot: ModelSlot, config: dict) -> None:
|
|
|
234
525
|
|
|
235
526
|
elif _runtime == "openvino":
|
|
236
527
|
core = _runtime_lib
|
|
237
|
-
ov_device = config.get("device", "AUTO").upper()
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
528
|
+
ov_device = str(config.get("device", "AUTO")).upper()
|
|
529
|
+
# Scrypted's exact device switch (see _resolve_ov_mode above): an
|
|
530
|
+
# AUTO/default request classifies the enumerated devices by their
|
|
531
|
+
# FULL_DEVICE_NAME and builds the same mode string Scrypted's
|
|
532
|
+
# OpenVINO plugin does. An operator-pinned device (config "device" =
|
|
533
|
+
# gpu/cpu/npu) is honored unchanged — compiled directly.
|
|
534
|
+
if ov_device in ("AUTO", "DEFAULT", ""):
|
|
535
|
+
available = list(core.available_devices)
|
|
536
|
+
full_names: dict[str, str] = {}
|
|
537
|
+
for dev in available:
|
|
538
|
+
try:
|
|
539
|
+
full_names[dev] = str(core.get_property(dev, "FULL_DEVICE_NAME"))
|
|
540
|
+
except Exception:
|
|
541
|
+
pass
|
|
542
|
+
ov_device = _resolve_ov_mode(available, full_names)
|
|
543
|
+
sys.stderr.write(
|
|
544
|
+
f"OpenVINO: AUTO resolved to mode {ov_device} "
|
|
545
|
+
f"(available: {available}, names: {full_names})\n"
|
|
546
|
+
)
|
|
547
|
+
sys.stderr.flush()
|
|
548
|
+
else:
|
|
549
|
+
sys.stderr.write(f"OpenVINO: operator-pinned device {ov_device}\n")
|
|
550
|
+
sys.stderr.flush()
|
|
247
551
|
# THROUGHPUT hint lets OpenVINO spin up multiple internal execution
|
|
248
552
|
# streams. Combined with one InferRequest per predict-pool thread
|
|
249
553
|
# (below), concurrent frames from N cameras run in parallel — the
|
|
@@ -251,8 +555,53 @@ def _load_model(slot: ModelSlot, config: dict) -> None:
|
|
|
251
555
|
# every camera serialised through one stream regardless of how many
|
|
252
556
|
# predict workers existed. Measured ~1.5–2.4x throughput on CPU/GPU/NPU.
|
|
253
557
|
ov_config = {"PERFORMANCE_HINT": "THROUGHPUT"}
|
|
254
|
-
compiled
|
|
255
|
-
|
|
558
|
+
# Persist compiled GPU/NPU kernels so the multi-second JIT compile is paid
|
|
559
|
+
# once (first boot) instead of on every pool spawn. The cold compile storm
|
|
560
|
+
# (~25s for the full model set on an N100 iGPU) is the window where the AUTO
|
|
561
|
+
# CPU->GPU handover races (see the index-fetch note below) and frames pile up.
|
|
562
|
+
try:
|
|
563
|
+
_cache_dir = os.path.join(os.path.dirname(os.path.abspath(path)), ".ov-cache")
|
|
564
|
+
os.makedirs(_cache_dir, exist_ok=True)
|
|
565
|
+
ov_config["CACHE_DIR"] = _cache_dir
|
|
566
|
+
except OSError:
|
|
567
|
+
pass
|
|
568
|
+
# Compile the resolved mode. Failure fallback is Scrypted's, exactly:
|
|
569
|
+
# any mode containing "GPU" reverts to plain "AUTO" and recompiles
|
|
570
|
+
# once; if THAT also fails (or the mode had no GPU), re-raise —
|
|
571
|
+
# camstack has no reset-and-restart like Scrypted, so the existing
|
|
572
|
+
# load/replace error handler reports the failure.
|
|
573
|
+
candidates = [ov_device]
|
|
574
|
+
if "GPU" in ov_device:
|
|
575
|
+
candidates.append("AUTO")
|
|
576
|
+
compiled = None
|
|
577
|
+
last_exc: Optional[BaseException] = None
|
|
578
|
+
for candidate in candidates:
|
|
579
|
+
try:
|
|
580
|
+
compiled = core.compile_model(path, device_name=candidate, config=ov_config)
|
|
581
|
+
ov_device = candidate
|
|
582
|
+
break
|
|
583
|
+
except Exception as exc:
|
|
584
|
+
last_exc = exc
|
|
585
|
+
sys.stderr.write(f"OpenVINO: compile with mode {candidate} failed: {exc}\n")
|
|
586
|
+
if candidate != candidates[-1]:
|
|
587
|
+
sys.stderr.write(f"OpenVINO: {candidate} mode failed, reverting to AUTO.\n")
|
|
588
|
+
sys.stderr.flush()
|
|
589
|
+
if compiled is None:
|
|
590
|
+
if last_exc is not None:
|
|
591
|
+
raise last_exc
|
|
592
|
+
raise RuntimeError("OpenVINO: no device available to compile the model")
|
|
593
|
+
# Name the resolved mode + what OpenVINO actually placed the model on
|
|
594
|
+
# (mirrors Scrypted's EXECUTION_DEVICES print) so a silent CPU
|
|
595
|
+
# placement is visible in the pool stderr.
|
|
596
|
+
try:
|
|
597
|
+
_exec_devices = compiled.get_property("EXECUTION_DEVICES")
|
|
598
|
+
except Exception:
|
|
599
|
+
_exec_devices = "unknown"
|
|
600
|
+
sys.stderr.write(
|
|
601
|
+
f"OpenVINO: compiled {os.path.basename(path)} mode={ov_device} "
|
|
602
|
+
f"EXECUTION_DEVICES={_exec_devices}\n"
|
|
603
|
+
)
|
|
604
|
+
sys.stderr.flush()
|
|
256
605
|
output_names = [o.get_any_name() for o in compiled.outputs]
|
|
257
606
|
|
|
258
607
|
# Record the device's optimal infer-request count so the dispatcher
|
|
@@ -270,27 +619,55 @@ def _load_model(slot: ModelSlot, config: dict) -> None:
|
|
|
270
619
|
# what lets the THROUGHPUT streams run concurrently.
|
|
271
620
|
_ov_tls = threading.local()
|
|
272
621
|
|
|
273
|
-
def predict(inp_dict: dict, _c=compiled,
|
|
622
|
+
def predict(inp_dict: dict, _c=compiled, _names=output_names, _tls=_ov_tls) -> dict:
|
|
274
623
|
req = getattr(_tls, "req", None)
|
|
275
624
|
if req is None:
|
|
276
625
|
req = _tls.req = _c.create_infer_request()
|
|
277
626
|
inp = list(inp_dict.values())[0]
|
|
278
|
-
|
|
279
|
-
|
|
627
|
+
try:
|
|
628
|
+
result = req.infer(inp)
|
|
629
|
+
except RuntimeError:
|
|
630
|
+
# AUTO CPU->GPU handover race: a request created during the
|
|
631
|
+
# CPU-helper phase (while the GPU JIT-compiles) cannot map the
|
|
632
|
+
# served model's output ports and raises — from INSIDE infer() —
|
|
633
|
+
# "Cannot find tensor for port ... score_8". (Reproduced live on
|
|
634
|
+
# the N100 iGPU under concurrent THROUGHPUT threads; the raise is
|
|
635
|
+
# in req.infer(), not the output fetch below.) Recreate the
|
|
636
|
+
# thread-local request post-handover and retry once.
|
|
637
|
+
req = _tls.req = _c.create_infer_request()
|
|
638
|
+
result = req.infer(inp)
|
|
639
|
+
# Fetch outputs by positional index (stable across the handover)
|
|
640
|
+
# rather than ConstOutput port identity — belt-and-braces.
|
|
641
|
+
return {name: result[i] for i, name in enumerate(_names)}
|
|
280
642
|
|
|
281
643
|
slot.model = compiled
|
|
282
644
|
slot.predict_fn = predict
|
|
283
645
|
|
|
284
646
|
elif _runtime == "onnxruntime":
|
|
285
647
|
ort = _runtime_lib
|
|
286
|
-
ort_device = config.get("device", "
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
648
|
+
ort_device = str(config.get("device", "") or "")
|
|
649
|
+
# Scrypted-style ordered providers — hardware EP first, CPU LAST as
|
|
650
|
+
# fallback (see _resolve_onnx_providers): a "cuda"/"coreml" pin puts
|
|
651
|
+
# that EP first, a "cpu" pin compiles CPU only, and the AUTO/default
|
|
652
|
+
# case derives the hardware EP from the platform.
|
|
653
|
+
try:
|
|
654
|
+
available_eps = list(ort.get_available_providers())
|
|
655
|
+
except Exception:
|
|
656
|
+
available_eps = [ONNX_CPU_EP]
|
|
657
|
+
providers = _resolve_onnx_providers(
|
|
658
|
+
ort_device, sys.platform, platform.machine(), available_eps,
|
|
659
|
+
)
|
|
293
660
|
session = ort.InferenceSession(path, providers=providers)
|
|
661
|
+
# Report the EP that ACTUALLY initialized — Scrypted strips CPU and
|
|
662
|
+
# logs the remainder — so a silent CPU fallback is visible.
|
|
663
|
+
_active_eps = [p for p in session.get_providers() if p != ONNX_CPU_EP]
|
|
664
|
+
_requested = [p[0] if isinstance(p, tuple) else p for p in providers]
|
|
665
|
+
sys.stderr.write(
|
|
666
|
+
f"ONNX: execution provider {', '.join(_active_eps) or ONNX_CPU_EP} "
|
|
667
|
+
f"(device={ort_device or 'auto'}, requested={_requested}, "
|
|
668
|
+
f"available={available_eps})\n"
|
|
669
|
+
)
|
|
670
|
+
sys.stderr.flush()
|
|
294
671
|
output_names = [o.name for o in session.get_outputs()]
|
|
295
672
|
slot.config["_input_name"] = session.get_inputs()[0].name
|
|
296
673
|
|
|
@@ -650,6 +1027,85 @@ class RuntimeDispatcher:
|
|
|
650
1027
|
self._predict_pool.shutdown(wait=False)
|
|
651
1028
|
|
|
652
1029
|
|
|
1030
|
+
# ---------------------------------------------------------------------------
|
|
1031
|
+
# Per-model backpressure — bound in-flight inference, drop-oldest shedding
|
|
1032
|
+
# ---------------------------------------------------------------------------
|
|
1033
|
+
|
|
1034
|
+
# Frames a single model may hold WAITING behind its running set before the
|
|
1035
|
+
# OLDEST waiting frame is shed. Running slots (= predict-pool size) keep the
|
|
1036
|
+
# device fed; a couple of pending frames cover dispatch gaps. Anything beyond
|
|
1037
|
+
# that only sits in an invisible executor queue inflating per-frame
|
|
1038
|
+
# "inference" time — on an overloaded node (N100 iGPU) that queue wait
|
|
1039
|
+
# ballooned to 9-22s per frame. Shedding it instead makes overload read as
|
|
1040
|
+
# `dropped` frames (fast `"dropped": true` responses) rather than latency.
|
|
1041
|
+
MAX_PENDING_PER_MODEL: int = 2
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
@dataclass
|
|
1045
|
+
class _ModelQueueState:
|
|
1046
|
+
"""Mutable per-model counters for ModelBackpressure."""
|
|
1047
|
+
running: int = 0
|
|
1048
|
+
pending: "deque[Any]" = field(default_factory=deque)
|
|
1049
|
+
|
|
1050
|
+
|
|
1051
|
+
class ModelBackpressure:
|
|
1052
|
+
"""Per-model in-flight bound with drop-oldest shedding.
|
|
1053
|
+
|
|
1054
|
+
Pure decision core — no asyncio, no I/O — so it is unit-testable
|
|
1055
|
+
(see test_inference_pool_backpressure.py). The event loop calls:
|
|
1056
|
+
|
|
1057
|
+
admit(model_idx, item) on every single-frame inference arrival.
|
|
1058
|
+
Returns (to_run, to_drop): items to dispatch NOW and items shed
|
|
1059
|
+
(oldest queued first). At most one of the lists is non-empty.
|
|
1060
|
+
complete(model_idx) when a dispatched inference finishes.
|
|
1061
|
+
Returns the next queued items to dispatch (0 or 1).
|
|
1062
|
+
|
|
1063
|
+
Why per-model and why these bounds: the predict pool has `workers`
|
|
1064
|
+
threads shared by all models; a model gets at most `max_running`
|
|
1065
|
+
concurrently-dispatched inferences (sized to the predict pool), plus
|
|
1066
|
+
a `max_pending` waiting buffer. The previous GLOBAL ceiling
|
|
1067
|
+
(`workers * 4`, min 16) was unreachable from the live pipeline — the
|
|
1068
|
+
TS runner's semaphore keeps at most `maxConcurrentInferences` frames
|
|
1069
|
+
in flight per node, well below 16 — so overload never shed frames; it
|
|
1070
|
+
just queued inside the executor as multi-second "inference" time.
|
|
1071
|
+
"""
|
|
1072
|
+
|
|
1073
|
+
def __init__(self, max_running: int, max_pending: int) -> None:
|
|
1074
|
+
self._max_running = max(1, max_running)
|
|
1075
|
+
self._max_pending = max(0, max_pending)
|
|
1076
|
+
self._states: dict[int, _ModelQueueState] = {}
|
|
1077
|
+
|
|
1078
|
+
def _state(self, model_idx: int) -> _ModelQueueState:
|
|
1079
|
+
state = self._states.get(model_idx)
|
|
1080
|
+
if state is None:
|
|
1081
|
+
state = _ModelQueueState()
|
|
1082
|
+
self._states[model_idx] = state
|
|
1083
|
+
return state
|
|
1084
|
+
|
|
1085
|
+
def admit(self, model_idx: int, item: Any) -> tuple[list, list]:
|
|
1086
|
+
"""Admit one inference request; returns (to_run, to_drop)."""
|
|
1087
|
+
state = self._state(model_idx)
|
|
1088
|
+
if state.running < self._max_running:
|
|
1089
|
+
state.running += 1
|
|
1090
|
+
return [item], []
|
|
1091
|
+
state.pending.append(item)
|
|
1092
|
+
dropped: list = []
|
|
1093
|
+
while len(state.pending) > self._max_pending:
|
|
1094
|
+
# DROP-OLDEST: the newest frame is the most valuable one for a
|
|
1095
|
+
# live detection pipeline — shed the stalest queued frame(s).
|
|
1096
|
+
dropped.append(state.pending.popleft())
|
|
1097
|
+
return [], dropped
|
|
1098
|
+
|
|
1099
|
+
def complete(self, model_idx: int) -> list:
|
|
1100
|
+
"""One running inference finished; returns queued items to start."""
|
|
1101
|
+
state = self._state(model_idx)
|
|
1102
|
+
state.running = max(0, state.running - 1)
|
|
1103
|
+
if state.pending and state.running < self._max_running:
|
|
1104
|
+
state.running += 1
|
|
1105
|
+
return [state.pending.popleft()]
|
|
1106
|
+
return []
|
|
1107
|
+
|
|
1108
|
+
|
|
653
1109
|
# ---------------------------------------------------------------------------
|
|
654
1110
|
# IPC — binary framing with request_id multiplexing
|
|
655
1111
|
# ---------------------------------------------------------------------------
|
|
@@ -832,6 +1288,17 @@ async def _run() -> None:
|
|
|
832
1288
|
concurrency = target
|
|
833
1289
|
|
|
834
1290
|
dispatcher = RuntimeDispatcher(runtime, concurrency)
|
|
1291
|
+
# Backpressure: bound in-flight single-frame inference PER MODEL, shedding
|
|
1292
|
+
# the OLDEST queued frame when full (see ModelBackpressure). max_running is
|
|
1293
|
+
# sized to the predict pool — dispatching more than `workers` concurrent
|
|
1294
|
+
# inferences for one model can't run any faster, it only queues invisibly
|
|
1295
|
+
# inside the executor and balloons per-frame "inference" time (9-22s
|
|
1296
|
+
# observed on the N100 iGPU). Healthy nodes never fill the pending buffer,
|
|
1297
|
+
# so this is a no-op except under genuine overload.
|
|
1298
|
+
backpressure = ModelBackpressure(
|
|
1299
|
+
max_running=dispatcher.workers,
|
|
1300
|
+
max_pending=MAX_PENDING_PER_MODEL,
|
|
1301
|
+
)
|
|
835
1302
|
startup_ms = round((time.perf_counter() - t_start) * 1000)
|
|
836
1303
|
loaded_count = sum(1 for s in models if s.loaded)
|
|
837
1304
|
sys.stderr.write(
|
|
@@ -963,6 +1430,34 @@ async def _run() -> None:
|
|
|
963
1430
|
"detections": [],
|
|
964
1431
|
"inferenceMs": 0,
|
|
965
1432
|
})
|
|
1433
|
+
finally:
|
|
1434
|
+
# Release this model's running slot; promote the next queued
|
|
1435
|
+
# frame (if any) into the freed slot immediately.
|
|
1436
|
+
for next_req_id, next_img in backpressure.complete(model_idx):
|
|
1437
|
+
asyncio.create_task(handle_inference(next_req_id, next_img, model_idx))
|
|
1438
|
+
|
|
1439
|
+
def _send_dropped(req_id: int) -> None:
|
|
1440
|
+
# Fast shed response — returned in microseconds instead of queuing the
|
|
1441
|
+
# frame for seconds. The TS side (shared-inference-pool.ts) recognises
|
|
1442
|
+
# `dropped: true` and counts it, so overload is measurable, not silent.
|
|
1443
|
+
asyncio.create_task(writer.send(req_id, {
|
|
1444
|
+
"kind": "detections",
|
|
1445
|
+
"detections": [],
|
|
1446
|
+
"inferenceMs": 0,
|
|
1447
|
+
"dropped": True,
|
|
1448
|
+
}))
|
|
1449
|
+
|
|
1450
|
+
def _dispatch_inference(req_id: int, img: Image.Image, model_idx: int) -> None:
|
|
1451
|
+
# Gate every single-frame inference through the per-model in-flight
|
|
1452
|
+
# bound. Slot accounting is synchronous (admit here, complete in the
|
|
1453
|
+
# handle_inference `finally`), so a slot can never leak past a task's
|
|
1454
|
+
# lifetime. Under overload the OLDEST queued frame is shed and answered
|
|
1455
|
+
# immediately so the caller never hangs on a frame we chose not to run.
|
|
1456
|
+
to_run, to_drop = backpressure.admit(model_idx, (req_id, img))
|
|
1457
|
+
for dropped_req_id, _dropped_img in to_drop:
|
|
1458
|
+
_send_dropped(dropped_req_id)
|
|
1459
|
+
for run_req_id, run_img in to_run:
|
|
1460
|
+
asyncio.create_task(handle_inference(run_req_id, run_img, model_idx))
|
|
966
1461
|
|
|
967
1462
|
async def handle_batch(req_id: int, model_idx: int, items: list[Image.Image]) -> None:
|
|
968
1463
|
if model_idx >= len(models) or not models[model_idx].loaded:
|
|
@@ -1045,7 +1540,7 @@ async def _run() -> None:
|
|
|
1045
1540
|
except Exception as exc:
|
|
1046
1541
|
await writer.send(req_id, {"error": f"jpeg decode failed: {exc}"})
|
|
1047
1542
|
continue
|
|
1048
|
-
|
|
1543
|
+
_dispatch_inference(req_id, img, model_idx)
|
|
1049
1544
|
|
|
1050
1545
|
elif msg_type == MSG_INFER_RAW:
|
|
1051
1546
|
if len(payload) < 10:
|
|
@@ -1060,7 +1555,7 @@ async def _run() -> None:
|
|
|
1060
1555
|
except Exception as exc:
|
|
1061
1556
|
await writer.send(req_id, {"error": f"raw wrap failed: {exc}"})
|
|
1062
1557
|
continue
|
|
1063
|
-
|
|
1558
|
+
_dispatch_inference(req_id, img, model_idx)
|
|
1064
1559
|
|
|
1065
1560
|
elif msg_type == MSG_INFER_BATCH:
|
|
1066
1561
|
# Header: [1B model_idx][1B count]
|
|
@@ -1130,7 +1625,7 @@ async def _run() -> None:
|
|
|
1130
1625
|
if model_idx >= len(models) or not models[model_idx].loaded:
|
|
1131
1626
|
await writer.send(req_id, {"error": f"model {model_idx} not loaded"})
|
|
1132
1627
|
continue
|
|
1133
|
-
|
|
1628
|
+
_dispatch_inference(req_id, img, model_idx)
|
|
1134
1629
|
|
|
1135
1630
|
else:
|
|
1136
1631
|
await writer.send(req_id, {"error": f"unknown msg_type: {msg_type}"})
|