@camstack/addon-pipeline 1.1.25 → 1.1.27

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