@trillboards/edge-sdk 0.2.81 → 0.2.82
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/deploy/detector/README.md +108 -21
- package/deploy/detector/bench/verify_against_reference.py +6 -1
- package/deploy/detector/pyproject.toml +1 -1
- package/deploy/detector/tests/test_loader.py +188 -0
- package/deploy/detector/tests/test_modelpath.py +48 -3
- package/deploy/detector/tests/test_registry.py +880 -0
- package/deploy/detector/tests/test_server.py +163 -7
- package/deploy/detector/tests/test_session.py +29 -10
- package/deploy/detector/trillboards_detector/__init__.py +1 -1
- package/deploy/detector/trillboards_detector/cli.py +49 -2
- package/deploy/detector/trillboards_detector/detections.py +98 -0
- package/deploy/detector/trillboards_detector/errors.py +38 -0
- package/deploy/detector/trillboards_detector/labels.py +239 -24
- package/deploy/detector/trillboards_detector/loader.py +186 -148
- package/deploy/detector/trillboards_detector/modelpath.py +32 -0
- package/deploy/detector/trillboards_detector/registry.py +836 -0
- package/deploy/detector/trillboards_detector/server.py +59 -40
- package/deploy/detector/trillboards_detector/session.py +84 -55
- package/dist/EdgeAgent.js +1 -1
- package/dist/diagnose.d.ts +1 -1
- package/dist/diagnose.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,836 @@
|
|
|
1
|
+
"""Many models on one box: who is resident, who gets evicted, and who answers.
|
|
2
|
+
|
|
3
|
+
WHY A REGISTRY AND NOT A SECOND `DetectorLoader`
|
|
4
|
+
------------------------------------------------
|
|
5
|
+
`docs/runbooks/sensing-model-library.md` §3 decides the shape and this module
|
|
6
|
+
implements it. Three of its sentences are the whole specification:
|
|
7
|
+
|
|
8
|
+
* "The loader holds a name -> session map with a class index built at load."
|
|
9
|
+
* "Models load LAZILY on first request for their classes, with LRU eviction
|
|
10
|
+
under a stated RAM budget."
|
|
11
|
+
* "A request spanning two models runs both and merges."
|
|
12
|
+
|
|
13
|
+
§2 is why it cannot be eager. The binding field box is a Ryzen 5 7640HS with a
|
|
14
|
+
measured 9.8 GB, carrying 6 of the 9 spec-reporting screens; Gemma's working set
|
|
15
|
+
alone is 3.31 GB and RF-DETR Medium peaks at 632 MB. "The sum has to fit, not
|
|
16
|
+
each member" — and a selector that approves each model independently approves a
|
|
17
|
+
set that OOM-kills the box, where the failure then looks like a camera problem.
|
|
18
|
+
|
|
19
|
+
STDLIB ONLY, LIKE `server.py`, AND FOR THE SAME REASON. The ONNX session arrives
|
|
20
|
+
through an injected factory, so every rule below — routing, admission,
|
|
21
|
+
eviction, the refusals — is exercisable on a bare interpreter with no
|
|
22
|
+
onnxruntime, no numpy, no Pillow and no 128 MiB artifact. That is every CI
|
|
23
|
+
runner. A residency policy that could only be tested on a box with the model
|
|
24
|
+
present is a residency policy nothing tests, which is the same argument
|
|
25
|
+
`test_server.py` already makes for the wire.
|
|
26
|
+
|
|
27
|
+
WHAT IS PINNED AND WHY IT IS NOT "EAGER LOADING BY ANOTHER NAME"
|
|
28
|
+
---------------------------------------------------------------
|
|
29
|
+
The DEFAULT model — the general cascade gate — is loaded by the watcher and is
|
|
30
|
+
never evicted. Specialists are lazy. That is not a softening of §3; it is what
|
|
31
|
+
makes §3 coexist with the health contract that shipped first:
|
|
32
|
+
|
|
33
|
+
* `/health` promises 200 only when "a load completed AND a probe inference
|
|
34
|
+
succeeded". Under pure lazy loading nothing is resident at startup, so
|
|
35
|
+
`/health` would answer 503 until the first request — and `LocalDetectorClient
|
|
36
|
+
.isHealthy()` returning false is precisely how the connector decides the
|
|
37
|
+
detector is down and sends every frame to the VLM. The cascade would then
|
|
38
|
+
never make its first call, and the 84.7%-of-calls saving would silently
|
|
39
|
+
never happen on any box.
|
|
40
|
+
* §3's arithmetic is about the SET: "loading all of them eagerly does not fit
|
|
41
|
+
the 9.8 GB box". One 632 MB model does, and no budget that can hold anything
|
|
42
|
+
at all cannot hold it.
|
|
43
|
+
* Evicting the general detector to make room for a specialist and reloading it
|
|
44
|
+
on the next frame is pure thrash — it is on the hot path for essentially
|
|
45
|
+
every profile.
|
|
46
|
+
|
|
47
|
+
CONCURRENCY. One coarse admission lock covers plan -> evict -> load, so two
|
|
48
|
+
requests can never each admit a model and together exceed the budget. Inference
|
|
49
|
+
itself runs OUTSIDE it, holding only strong references to the sessions, and a
|
|
50
|
+
model with a request in flight is not evictable — otherwise the accounting would
|
|
51
|
+
be describing a set that is not the resident set. `/health` takes a different,
|
|
52
|
+
short-lived lock and therefore never queues behind a load, which is the property
|
|
53
|
+
that keeps it cheap.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
from __future__ import annotations
|
|
57
|
+
|
|
58
|
+
import os
|
|
59
|
+
import sys
|
|
60
|
+
import threading
|
|
61
|
+
import time
|
|
62
|
+
from pathlib import Path
|
|
63
|
+
|
|
64
|
+
from .detections import DetectionBatch, merge_batches
|
|
65
|
+
from .errors import DetectorNotReady, ModelContractError, UnknownClasses
|
|
66
|
+
from .labels import ClassIndex, ModelVocabulary, normalize_class_name
|
|
67
|
+
|
|
68
|
+
#: Per-model residency states. `absent` and `idle` are new and they are
|
|
69
|
+
#: different facts: `absent` means the artifact is not on disk (it is OTA'd, so
|
|
70
|
+
#: on a fresh install that is the normal starting state), `idle` means the file
|
|
71
|
+
#: is there and no session is built — never loaded, or evicted to make room.
|
|
72
|
+
#: Collapsing them would make "the OTA has not landed" indistinguishable from
|
|
73
|
+
#: "the LRU reclaimed it", which are opposite problems.
|
|
74
|
+
STATE_ABSENT = "absent"
|
|
75
|
+
STATE_IDLE = "idle"
|
|
76
|
+
STATE_LOADING = "loading"
|
|
77
|
+
STATE_READY = "ready"
|
|
78
|
+
STATE_UNAVAILABLE = "unavailable"
|
|
79
|
+
|
|
80
|
+
#: THIS SERVICE OWNS NO RAM POLICY, AND MUST NOT.
|
|
81
|
+
#:
|
|
82
|
+
#: There is already an allocation authority and it is server-side:
|
|
83
|
+
#: `sensingProfileService.selectModelForDeviceTier` picks a model from
|
|
84
|
+
#: `deviceRamMb` + `deviceTier`, and `registerInternalControlSurface` gates each
|
|
85
|
+
#: model on `ramMb >= model.min_ram_mb`. A ceiling chosen HERE would be a second
|
|
86
|
+
#: authority that cannot see the first — the server deciding what a device gets
|
|
87
|
+
#: while the service independently decides what it keeps. That is the
|
|
88
|
+
#: `yolov8_nano` shape exactly: advertised by one component, unloadable by
|
|
89
|
+
#: another, for six months, with every surface green.
|
|
90
|
+
#:
|
|
91
|
+
#: So the ceiling is INJECTED and has NO DEFAULT. `None` does not mean
|
|
92
|
+
#: "unlimited" and does not mean a number this module picked; it means NOBODY
|
|
93
|
+
#: HAS TOLD US ONE, and the registry refuses to make any portfolio decision in
|
|
94
|
+
#: that state (see `_admit`).
|
|
95
|
+
#:
|
|
96
|
+
#: WHY THE SERVER CANNOT SIMPLY SUM A COLUMN IT ALREADY HAS: `min_ram_mb` is a
|
|
97
|
+
#: GATE, not a footprint. `gemma_4_e2b` carries `min_ram_mb` 8000,
|
|
98
|
+
#: `model_size_mb` 2580, and a measured working set of 3.31 GB — three different
|
|
99
|
+
#: quantities, and the one portfolio admission needs (resident footprint) is in
|
|
100
|
+
#: none of them. Summing `min_ram_mb` over-rejects; summing `model_size_mb`
|
|
101
|
+
#: under-rejects. `ModelVocabulary.approx_ram_bytes` is that missing quantity as
|
|
102
|
+
#: a COMMITTED figure, and `rss_delta_bytes` on /health is this service
|
|
103
|
+
#: MEASURING it per load so the authority above can eventually stop guessing.
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class ModelEntry:
|
|
107
|
+
"""One catalogue row: a committed vocabulary bound to a path on this box."""
|
|
108
|
+
|
|
109
|
+
__slots__ = ("vocabulary", "path", "pinned")
|
|
110
|
+
|
|
111
|
+
def __init__(self, vocabulary: ModelVocabulary, path: Path, pinned: bool = False):
|
|
112
|
+
self.vocabulary = vocabulary
|
|
113
|
+
self.path = Path(path)
|
|
114
|
+
self.pinned = pinned
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def name(self) -> str:
|
|
118
|
+
return self.vocabulary.model_name
|
|
119
|
+
|
|
120
|
+
def __repr__(self) -> str: # pragma: no cover - debugging aid
|
|
121
|
+
return f"ModelEntry({self.name!r}, {str(self.path)!r}, pinned={self.pinned})"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class _ModelState:
|
|
125
|
+
"""Everything mutable about one catalogue row. Guarded by the registry lock."""
|
|
126
|
+
|
|
127
|
+
def __init__(self, entry: ModelEntry):
|
|
128
|
+
self.entry = entry
|
|
129
|
+
# LOADING, NOT ABSENT, BEFORE THE FIRST `stat`.
|
|
130
|
+
#
|
|
131
|
+
# `absent` is a claim — "the artifact is not on this box" — and at
|
|
132
|
+
# construction nobody has looked. The service starts before the watcher
|
|
133
|
+
# thread does, so a `/health` probe can arrive in that window, and the
|
|
134
|
+
# single-model loader it replaced correctly said `loading` there. Saying
|
|
135
|
+
# `absent` would report a fact we had not established, and the three
|
|
136
|
+
# installers' post-install probes read exactly this word to decide
|
|
137
|
+
# whether to wait or to tell the operator the OTA has not landed.
|
|
138
|
+
self.state = STATE_LOADING
|
|
139
|
+
self.reason: "str | None" = None
|
|
140
|
+
self.session = None
|
|
141
|
+
self.stamp: "tuple[int, int] | None" = None
|
|
142
|
+
self.present = False
|
|
143
|
+
self.warmup_ms: "float | None" = None
|
|
144
|
+
self.loaded_at: "float | None" = None
|
|
145
|
+
self.last_used_at: "float | None" = None
|
|
146
|
+
self.detect_count = 0
|
|
147
|
+
self.detect_failures = 0
|
|
148
|
+
self.degenerate_dropped = 0
|
|
149
|
+
self.unnamed_class_dropped = 0
|
|
150
|
+
self.load_failures = 0
|
|
151
|
+
self.evictions = 0
|
|
152
|
+
self.in_flight = 0
|
|
153
|
+
#: MEASURED resident cost of this load, and the instrument that read it.
|
|
154
|
+
#: REPORTED, NEVER USED FOR ADMISSION — a service that admitted on its
|
|
155
|
+
#: own measurements would be back to being a second allocation
|
|
156
|
+
#: authority. This is the footprint column the server does not have
|
|
157
|
+
#: (`min_ram_mb` is a gate, `model_size_mb` is a file size), produced so
|
|
158
|
+
#: the authority above can eventually stop estimating.
|
|
159
|
+
self.rss_delta_bytes: "int | None" = None
|
|
160
|
+
self.rss_source: "str | None" = None
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def resident(self) -> bool:
|
|
164
|
+
return self.session is not None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class ModelRegistry:
|
|
168
|
+
"""The name -> session map, its class index, and the residency policy."""
|
|
169
|
+
|
|
170
|
+
def __init__(
|
|
171
|
+
self,
|
|
172
|
+
entries: "list[ModelEntry]",
|
|
173
|
+
session_factory,
|
|
174
|
+
probe,
|
|
175
|
+
ram_budget_bytes: "int | None",
|
|
176
|
+
log=print,
|
|
177
|
+
clock=time.monotonic,
|
|
178
|
+
):
|
|
179
|
+
if not entries:
|
|
180
|
+
raise ValueError("a registry with no models can answer nothing")
|
|
181
|
+
names = [e.name for e in entries]
|
|
182
|
+
if len(set(names)) != len(names):
|
|
183
|
+
raise ValueError(f"duplicate model names in the catalogue: {names}")
|
|
184
|
+
pinned = [e for e in entries if e.pinned]
|
|
185
|
+
if len(pinned) != 1:
|
|
186
|
+
raise ValueError(
|
|
187
|
+
"exactly one catalogue entry must be pinned — it is what `/health` reports and "
|
|
188
|
+
f"what the cascade gates on; got {[e.name for e in pinned]}"
|
|
189
|
+
)
|
|
190
|
+
if ram_budget_bytes is not None:
|
|
191
|
+
if ram_budget_bytes <= 0:
|
|
192
|
+
raise ValueError(f"an injected RAM ceiling must be positive, got {ram_budget_bytes}")
|
|
193
|
+
smallest_possible = pinned[0].vocabulary.approx_ram_bytes
|
|
194
|
+
if ram_budget_bytes < smallest_possible:
|
|
195
|
+
raise ValueError(
|
|
196
|
+
f"the injected RAM ceiling {ram_budget_bytes} B cannot hold the pinned model "
|
|
197
|
+
f"{pinned[0].name} ({smallest_possible} B); the service would be "
|
|
198
|
+
"permanently unavailable rather than merely constrained"
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
self._entries = list(entries)
|
|
202
|
+
self._session_factory = session_factory
|
|
203
|
+
self._probe = probe
|
|
204
|
+
#: `None` means NO CEILING WAS INJECTED — not "unlimited".
|
|
205
|
+
self._budget = None if ram_budget_bytes is None else int(ram_budget_bytes)
|
|
206
|
+
self._log = log
|
|
207
|
+
self._clock = clock
|
|
208
|
+
|
|
209
|
+
#: SHORT-LIVED. Guards the state dict and the counters, and nothing
|
|
210
|
+
#: slow ever happens under it — that is what keeps `/health` cheap.
|
|
211
|
+
self._lock = threading.Lock()
|
|
212
|
+
#: COARSE. Held across plan -> evict -> load so two concurrent requests
|
|
213
|
+
#: cannot both admit and together exceed the budget. Never held during
|
|
214
|
+
#: inference.
|
|
215
|
+
self._admission = threading.RLock()
|
|
216
|
+
|
|
217
|
+
self._evictions = 0
|
|
218
|
+
self._states = {e.name: _ModelState(e) for e in entries}
|
|
219
|
+
self._pinned_name = pinned[0].name
|
|
220
|
+
self._index: "ClassIndex | None" = None
|
|
221
|
+
|
|
222
|
+
# ── the catalogue's public face ──────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
@property
|
|
225
|
+
def pinned_name(self) -> str:
|
|
226
|
+
return self._pinned_name
|
|
227
|
+
|
|
228
|
+
@property
|
|
229
|
+
def ram_budget_bytes(self) -> "int | None":
|
|
230
|
+
return self._budget
|
|
231
|
+
|
|
232
|
+
def class_index(self) -> ClassIndex:
|
|
233
|
+
"""The union vocabulary of the models whose ARTIFACT IS ON THIS BOX.
|
|
234
|
+
|
|
235
|
+
Presence, not residency. A specialist that has been evicted is still
|
|
236
|
+
answerable — the next request for its classes reloads it — so gating the
|
|
237
|
+
offer on residency would make the requestable vocabulary depend on cache
|
|
238
|
+
state, and a caller would get a 400 or a 200 for the same profile
|
|
239
|
+
depending on what ran a minute ago. Presence is the honest line: the
|
|
240
|
+
artifact is here, so the question can be answered.
|
|
241
|
+
|
|
242
|
+
Gating on presence rather than on the committed list is also what keeps
|
|
243
|
+
§4 rule 5 true by construction — a vocabulary added to
|
|
244
|
+
`BUILTIN_VOCABULARIES` before its ONNX ships advertises nothing at all,
|
|
245
|
+
which is the `yolov8_nano` failure made unreachable rather than merely
|
|
246
|
+
forbidden.
|
|
247
|
+
|
|
248
|
+
Cached, and rebuilt only when presence changes: it is read on every
|
|
249
|
+
request.
|
|
250
|
+
"""
|
|
251
|
+
with self._lock:
|
|
252
|
+
if self._index is None:
|
|
253
|
+
self._index = ClassIndex(
|
|
254
|
+
[s.entry.vocabulary for s in self._states.values() if s.present]
|
|
255
|
+
)
|
|
256
|
+
return self._index
|
|
257
|
+
|
|
258
|
+
def resolve_classes(self, requested: "list[str]") -> "set[str]":
|
|
259
|
+
"""Canonicalise a caller's class list, or raise `UnknownClasses`.
|
|
260
|
+
|
|
261
|
+
ANY unknown is a refusal, not just all of them — the partial case is the
|
|
262
|
+
dangerous one and it becomes the COMMON one the moment specialists
|
|
263
|
+
exist. A PPE profile asks for `glove` and `hairnet` alongside `person`,
|
|
264
|
+
and on a box holding only the general detector the operator would
|
|
265
|
+
otherwise get person counts and silence about gloves.
|
|
266
|
+
|
|
267
|
+
The message distinguishes the two reasons a class can be unanswerable,
|
|
268
|
+
because they are opposite problems with opposite fixes: a class no build
|
|
269
|
+
has ever heard of is a profile bug, while a class a committed vocabulary
|
|
270
|
+
names but whose artifact has not landed is an OTA that has not arrived
|
|
271
|
+
yet and will fix itself.
|
|
272
|
+
"""
|
|
273
|
+
index = self.class_index()
|
|
274
|
+
canonical, unknown = index.resolve(requested)
|
|
275
|
+
if unknown:
|
|
276
|
+
raise UnknownClasses(unknown, index.class_names, self._unknown_detail(unknown, index))
|
|
277
|
+
if not canonical:
|
|
278
|
+
raise UnknownClasses([], index.class_names, "`classes` resolved to nothing requestable")
|
|
279
|
+
return canonical
|
|
280
|
+
|
|
281
|
+
def _unknown_detail(self, unknown: "list[str]", index: ClassIndex) -> str:
|
|
282
|
+
pending: "dict[str, str]" = {}
|
|
283
|
+
with self._lock:
|
|
284
|
+
for state in self._states.values():
|
|
285
|
+
if state.present:
|
|
286
|
+
continue
|
|
287
|
+
for raw in unknown:
|
|
288
|
+
if normalize_class_name(raw) in state.entry.vocabulary.class_names:
|
|
289
|
+
pending[raw] = state.entry.name
|
|
290
|
+
detail = f"requested classes not in this box's vocabulary: {unknown}. "
|
|
291
|
+
if pending:
|
|
292
|
+
detail += (
|
|
293
|
+
"Some are named by a model whose artifact has not arrived yet "
|
|
294
|
+
f"({sorted(set(pending.items()))}); it is OTA'd and this service picks it up "
|
|
295
|
+
"within one poll interval of it landing. "
|
|
296
|
+
)
|
|
297
|
+
detail += f"Known classes: {sorted(index.class_names)}"
|
|
298
|
+
return detail
|
|
299
|
+
|
|
300
|
+
# ── the watcher's entry point ────────────────────────────────────────────
|
|
301
|
+
|
|
302
|
+
def refresh(self) -> None:
|
|
303
|
+
"""Stat every artifact; load the pinned model; react to an OTA.
|
|
304
|
+
|
|
305
|
+
NO INFERENCE HAPPENS HERE EXCEPT THE PROBE, and no specialist is loaded:
|
|
306
|
+
a `stat` per catalogue row is the whole cost on the common path.
|
|
307
|
+
"""
|
|
308
|
+
for entry in self._entries:
|
|
309
|
+
self._refresh_one(entry)
|
|
310
|
+
|
|
311
|
+
def _refresh_one(self, entry: ModelEntry) -> None:
|
|
312
|
+
state = self._states[entry.name]
|
|
313
|
+
stamp = _file_stamp(entry.path)
|
|
314
|
+
|
|
315
|
+
if stamp is None:
|
|
316
|
+
with self._lock:
|
|
317
|
+
was_present, was_resident = state.present, state.resident
|
|
318
|
+
state.present = False
|
|
319
|
+
state.session = None
|
|
320
|
+
state.stamp = None
|
|
321
|
+
state.warmup_ms = None
|
|
322
|
+
state.loaded_at = None
|
|
323
|
+
state.state = STATE_ABSENT
|
|
324
|
+
state.reason = (
|
|
325
|
+
f"model artifact not found at {entry.path}; it arrives over the air and this "
|
|
326
|
+
"service will pick it up within one poll interval of it landing"
|
|
327
|
+
)
|
|
328
|
+
if was_present or was_resident:
|
|
329
|
+
self._index = None
|
|
330
|
+
if was_present or was_resident:
|
|
331
|
+
self._log(f"[detector] {entry.name}: artifact went away at {entry.path}")
|
|
332
|
+
return
|
|
333
|
+
|
|
334
|
+
with self._lock:
|
|
335
|
+
first_sighting = not state.present
|
|
336
|
+
state.present = True
|
|
337
|
+
if first_sighting:
|
|
338
|
+
self._index = None
|
|
339
|
+
unchanged = state.resident and state.stamp == stamp
|
|
340
|
+
failed_on_this_stamp = state.state == STATE_UNAVAILABLE and state.stamp == stamp
|
|
341
|
+
if not state.resident and state.state in (STATE_ABSENT, STATE_LOADING):
|
|
342
|
+
state.state = STATE_IDLE
|
|
343
|
+
state.reason = None
|
|
344
|
+
|
|
345
|
+
if unchanged:
|
|
346
|
+
return
|
|
347
|
+
|
|
348
|
+
if failed_on_this_stamp:
|
|
349
|
+
# The exact bytes that failed to load are still on disk. Retrying
|
|
350
|
+
# every poll would rebuild a session that cannot work, per poll,
|
|
351
|
+
# forever. The stamp moves when `ModelManager` rewrites the file,
|
|
352
|
+
# and that is the event worth reacting to.
|
|
353
|
+
return
|
|
354
|
+
|
|
355
|
+
with self._admission:
|
|
356
|
+
with self._lock:
|
|
357
|
+
was_resident = state.resident
|
|
358
|
+
if was_resident:
|
|
359
|
+
state.session = None
|
|
360
|
+
state.state = STATE_IDLE
|
|
361
|
+
state.warmup_ms = None
|
|
362
|
+
state.loaded_at = None
|
|
363
|
+
if was_resident:
|
|
364
|
+
self._log(f"[detector] {entry.name}: artifact changed on disk — dropping the session")
|
|
365
|
+
|
|
366
|
+
if entry.pinned:
|
|
367
|
+
# ONLY THE PINNED MODEL LOADS HERE, and the constructor
|
|
368
|
+
# guarantees it fits on its own. Rebuilding a specialist from
|
|
369
|
+
# the watcher would be an allocation nobody asked for, made
|
|
370
|
+
# outside a request that could have been refused for not
|
|
371
|
+
# fitting — the "approves each model independently" mistake
|
|
372
|
+
# §2 names. A dropped specialist is `idle`: present,
|
|
373
|
+
# answerable, one lazy load away on the next request for its
|
|
374
|
+
# classes.
|
|
375
|
+
self._ensure_loaded(state, stamp)
|
|
376
|
+
else:
|
|
377
|
+
with self._lock:
|
|
378
|
+
state.stamp = stamp
|
|
379
|
+
|
|
380
|
+
# ── the request path ─────────────────────────────────────────────────────
|
|
381
|
+
|
|
382
|
+
def detect(self, jpeg: bytes, min_confidence: float, wanted: "set[str] | None") -> DetectionBatch:
|
|
383
|
+
"""Run every model this request needs, in catalogue order, and merge.
|
|
384
|
+
|
|
385
|
+
ALL OR NOTHING. If any model in the plan cannot be made resident — the
|
|
386
|
+
artifact vanished, the load failed, the probe failed, or the set does not
|
|
387
|
+
fit the budget — the whole request is a `DetectorNotReady`, which the
|
|
388
|
+
server turns into a 503 and `LocalDetectorClient` into a
|
|
389
|
+
`DetectorUnavailableError`. Answering with the models that DID load
|
|
390
|
+
would be a thinner frame that nothing downstream can distinguish from a
|
|
391
|
+
quieter venue, which is the single failure this component exists to
|
|
392
|
+
prevent.
|
|
393
|
+
"""
|
|
394
|
+
plan = self.class_index().assign(wanted)
|
|
395
|
+
if not plan:
|
|
396
|
+
raise DetectorNotReady(
|
|
397
|
+
"no model on this box can answer that request; "
|
|
398
|
+
+ self._availability_summary()
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
acquired = self._acquire([vocabulary.model_name for vocabulary, _ in plan])
|
|
402
|
+
try:
|
|
403
|
+
batches = []
|
|
404
|
+
for (vocabulary, classes), session in zip(plan, acquired):
|
|
405
|
+
state = self._states[vocabulary.model_name]
|
|
406
|
+
try:
|
|
407
|
+
batch = session.detect(jpeg, min_confidence, classes)
|
|
408
|
+
except Exception:
|
|
409
|
+
with self._lock:
|
|
410
|
+
state.detect_failures += 1
|
|
411
|
+
raise
|
|
412
|
+
with self._lock:
|
|
413
|
+
state.detect_count += 1
|
|
414
|
+
# PER MODEL, not per service. A whole-service drop counter
|
|
415
|
+
# is the aggregation mistake `telemetry-producer-substrate.md`
|
|
416
|
+
# records: `vision_call_log.latency_ms` was NULL on 100% of
|
|
417
|
+
# local-vlm rows while the cloud model was 100% populated,
|
|
418
|
+
# and a whole-table fill check called that a healthy 92%.
|
|
419
|
+
# One specialist dropping every detection it makes must not
|
|
420
|
+
# be averaged into silence by the general detector's zero.
|
|
421
|
+
state.degenerate_dropped += batch.degenerate_dropped
|
|
422
|
+
state.unnamed_class_dropped += batch.unnamed_class_dropped
|
|
423
|
+
batches.append(batch)
|
|
424
|
+
finally:
|
|
425
|
+
self._release([vocabulary.model_name for vocabulary, _ in plan])
|
|
426
|
+
|
|
427
|
+
merged = merge_batches(batches)
|
|
428
|
+
if merged.degenerate_dropped or merged.unnamed_class_dropped:
|
|
429
|
+
self._log(
|
|
430
|
+
f"[detector] dropped {merged.degenerate_dropped} zero-extent box(es) and "
|
|
431
|
+
f"{merged.unnamed_class_dropped} unnamed-class detection(s) from one frame"
|
|
432
|
+
)
|
|
433
|
+
return merged
|
|
434
|
+
|
|
435
|
+
def _acquire(self, needed: "list[str]") -> list:
|
|
436
|
+
"""Make every named model resident under the budget, then pin it in flight.
|
|
437
|
+
|
|
438
|
+
The admission decision, the eviction and the loads all happen under one
|
|
439
|
+
lock. Splitting them is how two requests each pass a fit check and then
|
|
440
|
+
both allocate.
|
|
441
|
+
"""
|
|
442
|
+
with self._admission:
|
|
443
|
+
self._admit(needed)
|
|
444
|
+
sessions = []
|
|
445
|
+
taken: "list[str]" = []
|
|
446
|
+
try:
|
|
447
|
+
for name in needed:
|
|
448
|
+
state = self._states[name]
|
|
449
|
+
stamp = _file_stamp(state.entry.path)
|
|
450
|
+
if stamp is None:
|
|
451
|
+
raise DetectorNotReady(
|
|
452
|
+
f"{name}: model artifact not found at {state.entry.path}"
|
|
453
|
+
)
|
|
454
|
+
if not state.resident or state.stamp != stamp:
|
|
455
|
+
self._ensure_loaded(state, stamp)
|
|
456
|
+
with self._lock:
|
|
457
|
+
if state.session is None:
|
|
458
|
+
raise DetectorNotReady(f"{name}: {state.reason or 'model is not loaded'}")
|
|
459
|
+
state.in_flight += 1
|
|
460
|
+
state.last_used_at = self._clock()
|
|
461
|
+
taken.append(name)
|
|
462
|
+
sessions.append(state.session)
|
|
463
|
+
except BaseException:
|
|
464
|
+
# RELEASE WHAT WE ALREADY TOOK. A two-model plan whose second
|
|
465
|
+
# model fails to load would otherwise leave the first pinned
|
|
466
|
+
# in-flight for the life of the process — permanently
|
|
467
|
+
# unevictable, so the budget silently shrinks by one model every
|
|
468
|
+
# time a specialist's artifact is broken. A leak that only
|
|
469
|
+
# appears on the failure path is the kind nothing notices.
|
|
470
|
+
self._release(taken)
|
|
471
|
+
raise
|
|
472
|
+
return sessions
|
|
473
|
+
|
|
474
|
+
def _release(self, names: "list[str]") -> None:
|
|
475
|
+
with self._lock:
|
|
476
|
+
for name in names:
|
|
477
|
+
state = self._states[name]
|
|
478
|
+
if state.in_flight > 0:
|
|
479
|
+
state.in_flight -= 1
|
|
480
|
+
state.last_used_at = self._clock()
|
|
481
|
+
|
|
482
|
+
def _admit(self, needed: "list[str]") -> None:
|
|
483
|
+
"""Evict LRU until `needed` fits alongside what must stay, or refuse.
|
|
484
|
+
|
|
485
|
+
"Must stay" is the pinned model plus anything with a request in flight.
|
|
486
|
+
Evicting either would make the accounting describe a set that is not the
|
|
487
|
+
resident set — and in the in-flight case would not even free the memory,
|
|
488
|
+
since the running request still holds a reference.
|
|
489
|
+
"""
|
|
490
|
+
needed_set = set(needed)
|
|
491
|
+
with self._lock:
|
|
492
|
+
resident = {n for n, s in self._states.items() if s.resident}
|
|
493
|
+
in_flight = {n for n, s in self._states.items() if s.in_flight > 0}
|
|
494
|
+
cost = {n: s.entry.vocabulary.approx_ram_bytes for n, s in self._states.items()}
|
|
495
|
+
|
|
496
|
+
if self._budget is None:
|
|
497
|
+
# NO CEILING WAS INJECTED, so there is no portfolio decision this
|
|
498
|
+
# service is entitled to make. The pinned model is exempt because it
|
|
499
|
+
# is not a portfolio: the server ALREADY chose it — that is what
|
|
500
|
+
# `selectModelForDeviceTier` does — and holding what we were told to
|
|
501
|
+
# hold is the mechanism doing its job. Making a SECOND model resident
|
|
502
|
+
# is an allocation, and an allocation needs an authority.
|
|
503
|
+
#
|
|
504
|
+
# Refusing here rather than guessing is the whole correction: a
|
|
505
|
+
# number picked in this file would be a second authority that cannot
|
|
506
|
+
# see `min_ram_mb`, and the two would disagree silently.
|
|
507
|
+
unentitled = sorted(needed_set - {self._pinned_name})
|
|
508
|
+
if unentitled:
|
|
509
|
+
raise DetectorNotReady(
|
|
510
|
+
f"cannot admit {unentitled} — no RAM ceiling was injected, so this service "
|
|
511
|
+
"has no basis for a portfolio decision and will not invent one. The ceiling "
|
|
512
|
+
"comes from the allocation authority that already sizes models for this box "
|
|
513
|
+
"(sensingProfileService.selectModelForDeviceTier / the model-manifest "
|
|
514
|
+
"min_ram_mb gate); pass it as --ram-budget-mb or "
|
|
515
|
+
"TRILLBOARDS_DETECTOR_RAM_BUDGET_MB"
|
|
516
|
+
)
|
|
517
|
+
return
|
|
518
|
+
|
|
519
|
+
keep = needed_set | {self._pinned_name} | (resident & in_flight)
|
|
520
|
+
required = sum(cost[n] for n in keep)
|
|
521
|
+
if required > self._budget:
|
|
522
|
+
raise DetectorNotReady(
|
|
523
|
+
f"this request needs {sorted(needed_set)} resident at once, which with "
|
|
524
|
+
f"{sorted(keep - needed_set)} comes to {required} B against a "
|
|
525
|
+
f"{self._budget} B portfolio budget. Nothing was dropped to make it fit and "
|
|
526
|
+
"no partial answer was produced; raise --ram-budget-mb or narrow the profile"
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
evictable = sorted(
|
|
530
|
+
(n for n in resident if n not in keep),
|
|
531
|
+
key=lambda n: (self._states[n].last_used_at or 0.0),
|
|
532
|
+
)
|
|
533
|
+
projected = sum(cost[n] for n in resident | needed_set)
|
|
534
|
+
for name in evictable:
|
|
535
|
+
if projected <= self._budget:
|
|
536
|
+
break
|
|
537
|
+
self._evict(name)
|
|
538
|
+
projected -= cost[name]
|
|
539
|
+
|
|
540
|
+
if projected > self._budget: # pragma: no cover - `keep` bounds this
|
|
541
|
+
raise DetectorNotReady(
|
|
542
|
+
f"could not fit {sorted(needed_set)} in {self._budget} B even after evicting "
|
|
543
|
+
f"{evictable}; this is an accounting bug, not a capacity one"
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
def _evict(self, name: str) -> None:
|
|
547
|
+
"""Drop the session. Refcounting does the freeing.
|
|
548
|
+
|
|
549
|
+
Nothing else holds a reference: `_acquire` takes one only for the life
|
|
550
|
+
of a request and in-flight models are never chosen here, so the ORT
|
|
551
|
+
session is released the moment this assignment lands. The model stays
|
|
552
|
+
`idle` — present on disk, answerable, one load away.
|
|
553
|
+
"""
|
|
554
|
+
with self._lock:
|
|
555
|
+
state = self._states[name]
|
|
556
|
+
if state.session is None:
|
|
557
|
+
return
|
|
558
|
+
state.session = None
|
|
559
|
+
state.state = STATE_IDLE
|
|
560
|
+
state.reason = None
|
|
561
|
+
state.warmup_ms = None
|
|
562
|
+
state.loaded_at = None
|
|
563
|
+
state.evictions += 1
|
|
564
|
+
self._evictions += 1
|
|
565
|
+
# LOUD, AND COUNTED WHERE `/health` PUBLISHES IT. Eviction is a
|
|
566
|
+
# last-resort safety valve, not the allocator: a box evicting at all is
|
|
567
|
+
# oversubscribed against the ceiling it was given, and a service quietly
|
|
568
|
+
# thrashing under one looks exactly like a service working fine. The
|
|
569
|
+
# authority above needs to see this to correct the ceiling, so it is a
|
|
570
|
+
# WARNING and a counter, never a debug line.
|
|
571
|
+
self._log(
|
|
572
|
+
f"[detector] WARNING evicted {name} to stay inside the injected {self._budget} B "
|
|
573
|
+
f"ceiling — this box is oversubscribed against what it was allocated "
|
|
574
|
+
f"(evictions={self._evictions})"
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
def _ensure_loaded(self, state: _ModelState, stamp: "tuple[int, int]") -> None:
|
|
578
|
+
"""Build the session and PROVE IT before calling it ready.
|
|
579
|
+
|
|
580
|
+
A session that constructs is not a session that infers. `/v1/models`
|
|
581
|
+
answers 200 without ever building an engine, which is how a UTF-8 BOM in
|
|
582
|
+
litert-lm's config.json 500'd every frame on every Windows connector
|
|
583
|
+
while the installer's health check AND its CPU-fallback re-check both
|
|
584
|
+
passed. So every model — the pinned one on startup and every specialist
|
|
585
|
+
on its first request — runs a real forward pass on a synthetic frame
|
|
586
|
+
before it is allowed to answer anything.
|
|
587
|
+
"""
|
|
588
|
+
entry = state.entry
|
|
589
|
+
with self._lock:
|
|
590
|
+
state.state = STATE_LOADING
|
|
591
|
+
state.reason = None
|
|
592
|
+
|
|
593
|
+
before = read_process_rss()
|
|
594
|
+
try:
|
|
595
|
+
session = self._session_factory(entry)
|
|
596
|
+
except ModelContractError as err:
|
|
597
|
+
self._fail(state, stamp, f"artifact loaded but does not match the detector contract: {err}")
|
|
598
|
+
return
|
|
599
|
+
except Exception as err:
|
|
600
|
+
self._fail(state, stamp, f"could not load {entry.path}: {err}")
|
|
601
|
+
return
|
|
602
|
+
|
|
603
|
+
try:
|
|
604
|
+
started = time.perf_counter()
|
|
605
|
+
self._probe(session)
|
|
606
|
+
warmup_ms = (time.perf_counter() - started) * 1000.0
|
|
607
|
+
except Exception as err:
|
|
608
|
+
self._fail(state, stamp, f"artifact loaded but a probe inference failed: {err}")
|
|
609
|
+
return
|
|
610
|
+
|
|
611
|
+
# MEASURED AFTER THE PROBE, because a session that has not run a forward
|
|
612
|
+
# pass has not allocated its working buffers — the number an admission
|
|
613
|
+
# decision would need is the one AFTER inference, not after construction.
|
|
614
|
+
after = read_process_rss()
|
|
615
|
+
rss_delta = None
|
|
616
|
+
rss_source = None
|
|
617
|
+
if before is not None and after is not None and before[1] == after[1]:
|
|
618
|
+
rss_delta = max(0, after[0] - before[0])
|
|
619
|
+
rss_source = after[1]
|
|
620
|
+
|
|
621
|
+
with self._lock:
|
|
622
|
+
state.session = session
|
|
623
|
+
state.stamp = stamp
|
|
624
|
+
state.state = STATE_READY
|
|
625
|
+
state.reason = None
|
|
626
|
+
state.warmup_ms = warmup_ms
|
|
627
|
+
state.loaded_at = time.time()
|
|
628
|
+
state.last_used_at = self._clock()
|
|
629
|
+
state.rss_delta_bytes = rss_delta
|
|
630
|
+
state.rss_source = rss_source
|
|
631
|
+
self._index = None
|
|
632
|
+
measured = f", rss delta {rss_delta} B via {rss_source}" if rss_delta is not None else ""
|
|
633
|
+
self._log(
|
|
634
|
+
f"[detector] ready — {entry.name} at {entry.path} "
|
|
635
|
+
f"(probe {warmup_ms:.0f} ms, {len(entry.vocabulary.slot_names)} named classes{measured})"
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
def _fail(self, state: _ModelState, stamp: "tuple[int, int]", reason: str) -> None:
|
|
639
|
+
with self._lock:
|
|
640
|
+
state.session = None
|
|
641
|
+
state.stamp = stamp
|
|
642
|
+
state.state = STATE_UNAVAILABLE
|
|
643
|
+
state.reason = reason
|
|
644
|
+
state.warmup_ms = None
|
|
645
|
+
state.loaded_at = None
|
|
646
|
+
state.rss_delta_bytes = None
|
|
647
|
+
state.rss_source = None
|
|
648
|
+
state.load_failures += 1
|
|
649
|
+
self._index = None
|
|
650
|
+
self._log(f"[detector] {state.entry.name}: {reason}")
|
|
651
|
+
|
|
652
|
+
# ── what `/health` reads ─────────────────────────────────────────────────
|
|
653
|
+
|
|
654
|
+
def _availability_summary(self) -> str:
|
|
655
|
+
with self._lock:
|
|
656
|
+
return ", ".join(
|
|
657
|
+
f"{n}={s.state}" for n, s in sorted(self._states.items())
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
def snapshot(self) -> dict:
|
|
661
|
+
"""Everything `/health` needs, from state that already exists.
|
|
662
|
+
|
|
663
|
+
CANNOT LOAD A MODEL AND CANNOT INFER. It reads the outcome of loads that
|
|
664
|
+
already happened, under the short lock only, so a probe issued while a
|
|
665
|
+
specialist is loading answers immediately rather than queueing behind
|
|
666
|
+
it. Reporting a load that has not happened is the exact dishonesty
|
|
667
|
+
`GET /v1/models` shipped.
|
|
668
|
+
"""
|
|
669
|
+
with self._lock:
|
|
670
|
+
pinned = self._states[self._pinned_name]
|
|
671
|
+
resident_bytes = sum(
|
|
672
|
+
s.entry.vocabulary.approx_ram_bytes for s in self._states.values() if s.resident
|
|
673
|
+
)
|
|
674
|
+
models = {}
|
|
675
|
+
for name, s in self._states.items():
|
|
676
|
+
row = {
|
|
677
|
+
"state": s.state,
|
|
678
|
+
"path": str(s.entry.path),
|
|
679
|
+
"present": s.present,
|
|
680
|
+
"resident": s.resident,
|
|
681
|
+
"pinned": s.entry.pinned,
|
|
682
|
+
"named_classes": len(s.entry.vocabulary.slot_names),
|
|
683
|
+
"approx_ram_bytes": s.entry.vocabulary.approx_ram_bytes,
|
|
684
|
+
"detect_count": s.detect_count,
|
|
685
|
+
"detect_failures": s.detect_failures,
|
|
686
|
+
"degenerate_boxes_dropped": s.degenerate_dropped,
|
|
687
|
+
"unnamed_class_dropped": s.unnamed_class_dropped,
|
|
688
|
+
"load_failures": s.load_failures,
|
|
689
|
+
"evictions": s.evictions,
|
|
690
|
+
}
|
|
691
|
+
if s.reason:
|
|
692
|
+
row["reason"] = s.reason
|
|
693
|
+
if s.warmup_ms is not None:
|
|
694
|
+
row["warmup_inference_ms"] = round(s.warmup_ms, 1)
|
|
695
|
+
if s.loaded_at is not None:
|
|
696
|
+
row["loaded_at"] = s.loaded_at
|
|
697
|
+
if s.rss_delta_bytes is not None:
|
|
698
|
+
# NAMED WITH ITS INSTRUMENT. `rusage-maxrss` is a high-water
|
|
699
|
+
# mark, so its delta is a LOWER BOUND and 0 is a legitimate
|
|
700
|
+
# reading; `proc-statm` and `psapi-workingset` are current
|
|
701
|
+
# RSS. A reader that cannot tell them apart will average the
|
|
702
|
+
# two into a number that describes neither.
|
|
703
|
+
row["rss_delta_bytes"] = s.rss_delta_bytes
|
|
704
|
+
row["rss_source"] = s.rss_source
|
|
705
|
+
if s.session is not None and getattr(s.session, "spec", None) is not None:
|
|
706
|
+
spec = s.session.spec
|
|
707
|
+
row["input"] = f"{spec.width}x{spec.height}"
|
|
708
|
+
row["classes"] = spec.num_classes
|
|
709
|
+
row["providers"] = list(spec.providers)
|
|
710
|
+
models[name] = row
|
|
711
|
+
|
|
712
|
+
# Counted from `present` directly rather than through
|
|
713
|
+
# `class_index()`, which takes this same non-reentrant lock. It is a
|
|
714
|
+
# set union over at most a handful of committed frozensets — no
|
|
715
|
+
# session is touched and nothing is built that a request would then
|
|
716
|
+
# have to wait for.
|
|
717
|
+
known = len(
|
|
718
|
+
frozenset().union(
|
|
719
|
+
*[s.entry.vocabulary.class_names for s in self._states.values() if s.present]
|
|
720
|
+
) if any(s.present for s in self._states.values()) else frozenset()
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
return {
|
|
724
|
+
"pinned_model": self._pinned_name,
|
|
725
|
+
"pinned": models[self._pinned_name],
|
|
726
|
+
"pinned_state": pinned.state,
|
|
727
|
+
"models": models,
|
|
728
|
+
"resident_models": sorted(n for n, s in self._states.items() if s.resident),
|
|
729
|
+
# THE CEILING AS INJECTED — `null` when nobody told us one,
|
|
730
|
+
# which is a state a reader must be able to see rather than a
|
|
731
|
+
# number this service picked.
|
|
732
|
+
"ram_budget_bytes": self._budget,
|
|
733
|
+
"ram_ceiling_injected": self._budget is not None,
|
|
734
|
+
"ram_resident_bytes": resident_bytes,
|
|
735
|
+
#: Non-zero means this box is OVERSUBSCRIBED against the ceiling
|
|
736
|
+
#: it was given. Published because a service quietly thrashing
|
|
737
|
+
#: under one is indistinguishable from a service working fine.
|
|
738
|
+
"evictions": self._evictions,
|
|
739
|
+
"known_class_count": known,
|
|
740
|
+
"detect_count": sum(s.detect_count for s in self._states.values()),
|
|
741
|
+
"detect_failures": sum(s.detect_failures for s in self._states.values()),
|
|
742
|
+
"degenerate_boxes_dropped": sum(s.degenerate_dropped for s in self._states.values()),
|
|
743
|
+
"unnamed_class_dropped": sum(s.unnamed_class_dropped for s in self._states.values()),
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
_PAGE_SIZE = os.sysconf("SC_PAGE_SIZE") if hasattr(os, "sysconf") else 4096
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def read_process_rss() -> "tuple[int, str] | None":
|
|
751
|
+
"""This process's resident set, in bytes, and HOW IT WAS READ.
|
|
752
|
+
|
|
753
|
+
The source is returned with the number because the three platforms do not
|
|
754
|
+
answer the same question and a caller that cannot tell them apart will
|
|
755
|
+
average a high-water mark into a current reading. `telemetry-producer-substrate.md`
|
|
756
|
+
is a whole runbook about that: `vision_call_log.latency_ms` was NULL on 100%
|
|
757
|
+
of `local-vlm` rows while the cloud model was 100% populated, and a
|
|
758
|
+
whole-table fill check reported a healthy 92% — grade a column PER PRODUCER,
|
|
759
|
+
never in aggregate.
|
|
760
|
+
|
|
761
|
+
proc-statm Linux. CURRENT RSS, exact, one small file read.
|
|
762
|
+
psapi-workingset Windows. CURRENT working set, exact, one syscall.
|
|
763
|
+
rusage-maxrss macOS and the fallback. A HIGH-WATER MARK for the whole
|
|
764
|
+
process, so a delta across a load is a LOWER BOUND and
|
|
765
|
+
legitimately reads 0 once the peak is behind us.
|
|
766
|
+
|
|
767
|
+
Stdlib only, and cheap enough to sit on a load path that already spends
|
|
768
|
+
hundreds of milliseconds building an ORT session.
|
|
769
|
+
"""
|
|
770
|
+
try:
|
|
771
|
+
with open("/proc/self/statm", "rb") as handle:
|
|
772
|
+
fields = handle.read().split()
|
|
773
|
+
if len(fields) >= 2:
|
|
774
|
+
return int(fields[1]) * _PAGE_SIZE, "proc-statm"
|
|
775
|
+
except (OSError, ValueError):
|
|
776
|
+
pass
|
|
777
|
+
|
|
778
|
+
if sys.platform == "win32": # pragma: no cover - exercised on Windows only
|
|
779
|
+
try:
|
|
780
|
+
import ctypes
|
|
781
|
+
from ctypes import wintypes
|
|
782
|
+
|
|
783
|
+
class _Counters(ctypes.Structure):
|
|
784
|
+
_fields_ = [
|
|
785
|
+
("cb", wintypes.DWORD),
|
|
786
|
+
("PageFaultCount", wintypes.DWORD),
|
|
787
|
+
("PeakWorkingSetSize", ctypes.c_size_t),
|
|
788
|
+
("WorkingSetSize", ctypes.c_size_t),
|
|
789
|
+
("QuotaPeakPagedPoolUsage", ctypes.c_size_t),
|
|
790
|
+
("QuotaPagedPoolUsage", ctypes.c_size_t),
|
|
791
|
+
("QuotaPeakNonPagedPoolUsage", ctypes.c_size_t),
|
|
792
|
+
("QuotaNonPagedPoolUsage", ctypes.c_size_t),
|
|
793
|
+
("PagefileUsage", ctypes.c_size_t),
|
|
794
|
+
("PeakPagefileUsage", ctypes.c_size_t),
|
|
795
|
+
]
|
|
796
|
+
|
|
797
|
+
counters = _Counters()
|
|
798
|
+
counters.cb = ctypes.sizeof(_Counters)
|
|
799
|
+
handle = ctypes.windll.kernel32.GetCurrentProcess()
|
|
800
|
+
if ctypes.windll.psapi.GetProcessMemoryInfo(
|
|
801
|
+
handle, ctypes.byref(counters), counters.cb
|
|
802
|
+
):
|
|
803
|
+
return int(counters.WorkingSetSize), "psapi-workingset"
|
|
804
|
+
except Exception:
|
|
805
|
+
pass
|
|
806
|
+
|
|
807
|
+
try:
|
|
808
|
+
import resource
|
|
809
|
+
|
|
810
|
+
raw = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
|
|
811
|
+
# macOS reports bytes; Linux and most others report kilobytes.
|
|
812
|
+
scale = 1 if sys.platform == "darwin" else 1024
|
|
813
|
+
return int(raw) * scale, "rusage-maxrss"
|
|
814
|
+
except Exception: # pragma: no cover - no stdlib path left
|
|
815
|
+
return None
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def _file_stamp(path: Path) -> "tuple[int, int] | None":
|
|
819
|
+
try:
|
|
820
|
+
st = path.stat()
|
|
821
|
+
except OSError:
|
|
822
|
+
return None
|
|
823
|
+
if st.st_size == 0:
|
|
824
|
+
return None
|
|
825
|
+
return (st.st_size, st.st_mtime_ns)
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
__all__ = [
|
|
829
|
+
"ModelEntry",
|
|
830
|
+
"ModelRegistry",
|
|
831
|
+
"STATE_ABSENT",
|
|
832
|
+
"STATE_IDLE",
|
|
833
|
+
"STATE_LOADING",
|
|
834
|
+
"STATE_READY",
|
|
835
|
+
"STATE_UNAVAILABLE",
|
|
836
|
+
]
|