refactor(perception): share bounded streaming scheduler

This commit is contained in:
DCCONSTRUCTIONS
2026-09-02 10:57:41 +03:00
parent adcca7aa81
commit bcacb02a22
5 changed files with 410 additions and 161 deletions
@@ -1,74 +1,5 @@
"""One producer, one graph, bounded pending queue and active payload accounting."""
"""Compatibility import; the bounded mailbox belongs to the shared runtime."""
from collections import deque
from threading import Condition
from k1link.perception.streaming_queue import StreamMailbox as Mailbox
class Mailbox:
def __init__(self, capacity=2, byte_limit=16 * 1024 * 1024):
self.capacity = capacity
self.byte_limit = byte_limit
self.condition = Condition()
self.pending = deque()
self.bytes = 0
self.peak_bytes = 0
self.peak_pending = 0
self.external_pending = 0
self.dropped = []
self.done = False
self.error = None
def put(self, bundle):
size = bundle["payload_bytes"]
with self.condition:
while self.pending and (
len(self.pending) + self.external_pending >= self.capacity
or self.bytes + size > self.byte_limit
):
old = self.pending.popleft()
self.bytes -= old["payload_bytes"]
self.dropped.append({"sequence": old["sequence"], "reason": "pending-overflow"})
if len(self.pending) + self.external_pending >= self.capacity:
self.dropped.append({"sequence": bundle["sequence"], "reason": "pending-overflow"})
return
if self.bytes + size > self.byte_limit:
self.dropped.append({"sequence": bundle["sequence"], "reason": "byte-budget"})
return
self.pending.append(bundle)
self.bytes += size
self.peak_bytes = max(self.peak_bytes, self.bytes)
self.peak_pending = max(self.peak_pending, len(self.pending) + self.external_pending)
self.condition.notify_all()
def reserve_completed(self):
"""A finished GPU result shares the SAME pending budget as ingress."""
with self.condition:
if self.external_pending:
raise ValueError("only one completed GPU slot is permitted")
while len(self.pending) + 1 > self.capacity:
old = self.pending.popleft()
self.bytes -= old["payload_bytes"]
self.dropped.append({"sequence": old["sequence"], "reason": "handoff-overflow"})
self.external_pending = 1
self.peak_pending = max(self.peak_pending, len(self.pending) + 1)
def take_completed(self):
with self.condition:
if self.external_pending != 1:
raise ValueError("completed GPU slot accounting mismatch")
self.external_pending = 0
def take(self):
with self.condition:
self.condition.wait_for(lambda: self.pending or self.done)
return self.pending.popleft() if self.pending else None
def release(self, bundle):
with self.condition:
self.bytes -= bundle["payload_bytes"]
def finish(self, error=None):
with self.condition:
self.done = True
self.error = error
self.condition.notify_all()
__all__ = ["Mailbox"]
@@ -1,91 +1,5 @@
"""One serial GPU stage can overlap one chronological CPU fusion stage.
"""Compatibility import; no separate per-LAB GPU scheduling implementation."""
No GPU model concurrency. Ingress and completed-GPU results share two pending
slots. Active payloads remain in the shared mailbox byte budget.
"""
from k1link.perception.streaming_scheduler import SerialGpuStage as GpuStage
import threading
import traceback
class GpuStage:
def __init__(self, mailbox, compute, stop):
self.mailbox = mailbox
self.compute = compute
self.stop = stop
self.output_condition = threading.Condition()
self.completed = None
self.completed_reserved = False
self.consumer_waiting = False
# Reserve the output slot BEFORE starting another GPU call. Otherwise a
# blocked put would hide a third pending frame outside the two queues.
self.output_slot = threading.Semaphore(1)
self.finished = threading.Event()
self.error = None
self.peak_pending = 0
self.direct_handoffs = 0
self.buffered_handoffs = 0
self.thread = threading.Thread(target=self._run, daemon=True)
self.thread.start()
def _run(self):
try:
while not self.stop.is_set():
if not self.output_slot.acquire(timeout=0.05):
continue
bundle = self.mailbox.take()
if bundle is None:
break
result = self.compute(bundle)
with self.output_condition:
if self.completed is not None:
raise ValueError("completed GPU slot is already occupied")
# A receiver already blocked in take() accepts the result as
# a synchronous rendezvous. It never becomes pending and
# therefore must not evict a newer ingress frame merely for
# the few instructions between publish and receive.
reserved = not self.consumer_waiting
if reserved:
self.mailbox.reserve_completed()
self.buffered_handoffs += 1
else:
self.direct_handoffs += 1
self.completed = (bundle, result)
self.completed_reserved = reserved
self.peak_pending = max(self.peak_pending, int(reserved))
self.output_condition.notify_all()
except Exception:
self.error = traceback.format_exc()
finally:
self.finished.set()
with self.output_condition:
self.output_condition.notify_all()
def take(self):
while True:
with self.output_condition:
if self.completed is not None:
result = self.completed
reserved = self.completed_reserved
self.completed = None
self.completed_reserved = False
self.consumer_waiting = False
elif self.finished.is_set():
self.consumer_waiting = False
if self.error:
raise RuntimeError(self.error) from None
return None
else:
self.consumer_waiting = True
self.output_condition.wait(timeout=0.05)
continue
if reserved:
self.mailbox.take_completed()
self.output_slot.release()
return result
def close(self):
self.stop.set()
self.mailbox.finish(self.mailbox.error)
self.thread.join(timeout=2)
return not self.thread.is_alive()
__all__ = ["GpuStage"]
+147
View File
@@ -0,0 +1,147 @@
"""Bounded stream ingress shared by serial GPU and chronological CPU stages.
No source duration, recording path, model, lease acquisition, or output history.
Active inputs remain byte-accounted until their owner releases them. A completed
GPU result shares the pending budget with ingress; it is not a third queue.
These are scheduler primitives, not a replacement for graph terminal outcomes.
"""
from __future__ import annotations
from collections import Counter, deque
from collections.abc import Mapping
from threading import Condition
from typing import Any
StreamBundle = Mapping[str, Any]
DISCARD_REASONS = frozenset(("cancelled", "gpu-failed", "processing-failed"))
class StreamMailbox:
def __init__(self, capacity: int = 2, byte_limit: int = 16 * 1024 * 1024) -> None:
if type(capacity) is not int or not 1 <= capacity <= 2:
raise ValueError("pending capacity must be 1..2")
if type(byte_limit) is not int or not 1 <= byte_limit <= 16 * 1024 * 1024:
raise ValueError("inflight byte budget must be 1..16 MiB")
self.capacity, self.byte_limit = capacity, byte_limit
self.condition = Condition()
self.pending: deque[StreamBundle] = deque()
self.bytes = self.peak_bytes = self.peak_pending = self.external_pending = 0
self.drop_counts: Counter[str] = Counter()
self._recent_drops: deque[dict[str, Any]] = deque(maxlen=256)
self._owned: dict[int, tuple[StreamBundle, int]] = {}
self._active: set[int] = set()
self._last_sequence = -1
self.done = False
self.error: str | None = None
@property
def dropped(self) -> list[dict[str, Any]]:
"""Bounded diagnostic tail, NOT an all-session terminal journal."""
with self.condition:
return list(self._recent_drops)
@property
def dropped_count(self) -> int:
with self.condition:
return sum(self.drop_counts.values())
@property
def quiescent(self) -> bool:
with self.condition:
return self.done and not self._owned and not self.external_pending
def _drop(self, bundle: StreamBundle, reason: str) -> None:
self.drop_counts[reason] += 1
self._recent_drops.append({"sequence": bundle["sequence"], "reason": reason})
def _discard_pending(self, reason: str) -> None:
old = self.pending.popleft()
_, size = self._owned.pop(id(old))
self.bytes -= size
self._drop(old, reason)
def put(self, bundle: StreamBundle) -> bool:
size, sequence = bundle["payload_bytes"], bundle["sequence"]
if type(size) is not int or size <= 0 or type(sequence) is not int or sequence < 0:
raise ValueError("invalid source sequence or payload size")
with self.condition:
if id(bundle) in self._owned:
raise ValueError("input is already owned")
if sequence <= self._last_sequence:
raise ValueError("stream sequence must strictly increase")
self._last_sequence = sequence
if self.done or size > self.byte_limit:
self._drop(bundle, "ingress-closed" if self.done else "byte-budget")
return False
while self.pending and (
len(self.pending) + self.external_pending >= self.capacity
or self.bytes + size > self.byte_limit
):
self._discard_pending("pending-overflow")
if len(self.pending) + self.external_pending >= self.capacity:
self._drop(bundle, "pending-overflow")
return False
if self.bytes + size > self.byte_limit:
self._drop(bundle, "byte-budget")
return False
self.pending.append(bundle)
self._owned[id(bundle)] = (bundle, size)
self.bytes += size
self.peak_bytes = max(self.peak_bytes, self.bytes)
self.peak_pending = max(self.peak_pending, len(self.pending) + self.external_pending)
self.condition.notify_all()
return True
def reserve_completed(self) -> None:
with self.condition:
if self.external_pending:
raise ValueError("only one completed GPU slot is permitted")
while len(self.pending) + 1 > self.capacity:
self._discard_pending("handoff-overflow")
self.external_pending = 1
self.peak_pending = max(self.peak_pending, len(self.pending) + 1)
def take_completed(self) -> None:
with self.condition:
if self.external_pending != 1:
raise ValueError("completed GPU slot accounting mismatch")
self.external_pending = 0
def take(self) -> StreamBundle | None:
with self.condition:
self.condition.wait_for(lambda: self.pending or self.done)
if not self.pending:
return None
bundle = self.pending.popleft()
self._active.add(id(bundle))
return bundle
def release(self, bundle: StreamBundle, *, discard_reason: str | None = None) -> None:
if discard_reason is not None and discard_reason not in DISCARD_REASONS:
raise ValueError("unknown discard reason")
with self.condition:
if id(bundle) not in self._active or self._owned[id(bundle)][0] is not bundle:
raise ValueError("input release ownership mismatch")
self._active.remove(id(bundle))
_, size = self._owned.pop(id(bundle))
self.bytes -= size
if discard_reason is not None:
self._drop(bundle, discard_reason)
def finish(self, error: str | None = None) -> None:
"""Close ingress, allowing accepted pending work to drain."""
with self.condition:
self.done = True
self.error = self.error or error
self.condition.notify_all()
def cancel(self, reason: str = "cancelled") -> None:
"""Discard pending work, but never free another stage's active input."""
if reason not in DISCARD_REASONS:
raise ValueError("unknown cancellation reason")
with self.condition:
self.done = True
while self.pending:
self._discard_pending(reason)
self.condition.notify_all()
@@ -0,0 +1,116 @@
"""One serial GPU lane overlapped with one chronological CPU consumer.
The callback owns all GPU model calls for a frame (e.g. DDRNet then RF-DETR).
This class does not acquire the Worker lease or prove external GPU exclusivity.
Close is cooperative: False means an active callback still owns resources and
the supervisor must not admit another profile. No thread is forcefully killed.
"""
from __future__ import annotations
import threading
import traceback
from collections.abc import Callable
from typing import Any
from .streaming_queue import StreamBundle, StreamMailbox
class SerialGpuStage:
def __init__(
self, mailbox: StreamMailbox, compute: Callable[[StreamBundle], Any], stop: threading.Event
) -> None:
self.mailbox, self.compute, self.stop = mailbox, compute, stop
self.output_condition = threading.Condition()
self.completed: tuple[StreamBundle, Any] | None = None
self.completed_reserved = self.consumer_waiting = False
self.output_slot = threading.Semaphore(1)
self.finished = threading.Event()
self.error: str | None = None
self.peak_pending = self.direct_handoffs = self.buffered_handoffs = 0
self.thread = threading.Thread(target=self._run, name="perception-serial-gpu", daemon=True)
self.thread.start()
def _run(self) -> None:
active = None
try:
while not self.stop.is_set():
# Reserve output BEFORE inference: no hidden third pending result.
if not self.output_slot.acquire(timeout=0.05):
continue
active = self.mailbox.take()
if active is None:
break
if self.stop.is_set():
break
result = self.compute(active)
with self.output_condition:
if self.stop.is_set():
break
if self.completed is not None:
raise ValueError("completed GPU slot is already occupied")
# A blocked consumer accepts a synchronous rendezvous, not
# a pending slot that would spuriously evict newer ingress.
reserved = not self.consumer_waiting
if reserved:
self.mailbox.reserve_completed()
self.buffered_handoffs += 1
else:
self.direct_handoffs += 1
self.completed = (active, result)
active = None
self.completed_reserved = reserved
self.peak_pending = max(self.peak_pending, int(reserved))
self.output_condition.notify_all()
except Exception:
self.error = traceback.format_exc()
self.mailbox.finish(self.error)
self.mailbox.cancel("gpu-failed")
finally:
if active is not None:
self.mailbox.release(
active, discard_reason="gpu-failed" if self.error else "cancelled"
)
self.finished.set()
with self.output_condition:
self.output_condition.notify_all()
def take(self) -> tuple[StreamBundle, Any] | None:
while True:
with self.output_condition:
if self.stop.is_set():
self.consumer_waiting = False
return None
if self.completed is not None:
result = self.completed
reserved = self.completed_reserved
self.completed = None
self.completed_reserved = self.consumer_waiting = False
elif self.finished.is_set():
self.consumer_waiting = False
if self.error:
raise RuntimeError(self.error) from None
return None
else:
self.consumer_waiting = True
self.output_condition.wait(timeout=0.05)
continue
if reserved:
self.mailbox.take_completed()
self.output_slot.release()
return result
def close(self, *, timeout: float = 2.0) -> bool:
if not 0 <= timeout <= 2:
raise ValueError("stop timeout must be 0..2 seconds")
self.stop.set()
self.mailbox.cancel()
self.thread.join(timeout=timeout)
with self.output_condition:
if self.completed is not None:
if self.completed_reserved:
self.mailbox.take_completed()
self.mailbox.release(self.completed[0], discard_reason="cancelled")
self.completed = None
self.completed_reserved = False
return not self.thread.is_alive()
+141
View File
@@ -0,0 +1,141 @@
"""Small synthetic scheduler lifecycle tests; no models, GPU or source IO."""
import threading
import pytest
from k1link.perception.streaming_queue import StreamMailbox
from k1link.perception.streaming_scheduler import SerialGpuStage
def bundle(sequence, size=10):
return {"sequence": sequence, "payload_bytes": size}
def test_finish_drains_but_cancel_drops_pending_without_freeing_active_owner():
queue = StreamMailbox()
first = bundle(0)
queue.put(first)
assert queue.take() is first
queue.put(bundle(1))
queue.finish()
assert not queue.put(bundle(2))
assert queue.bytes == 20 and not queue.quiescent
queue.cancel()
assert queue.take() is None and queue.bytes == 10
assert queue.drop_counts == {"ingress-closed": 1, "cancelled": 1}
queue.release(first)
assert queue.quiescent
with pytest.raises(ValueError, match="ownership"):
queue.release(first)
def test_oversized_message_does_not_evict_accepted_small_input():
queue = StreamMailbox(byte_limit=100)
first = bundle(0)
queue.put(first)
assert not queue.put(bundle(1, 101))
assert queue.take() is first and queue.bytes == 10
queue.release(first)
def test_diagnostic_history_is_bounded_and_total_drops_remain_exact():
queue = StreamMailbox()
# Boundary-sized synthetic fixture, not a local sustained-load benchmark.
for sequence in range(260):
queue.put(bundle(sequence))
assert queue.dropped_count == 258 and len(queue.dropped) == 256
assert queue.peak_pending == 2 and queue.bytes == 20
queue.cancel()
assert queue.dropped_count == 260 and queue.quiescent
@pytest.mark.parametrize("sequence,size", [(True, 1), (-1, 1), (0, 0), (0, True)])
def test_invalid_admission_is_rejected(sequence, size):
queue = StreamMailbox()
with pytest.raises(ValueError):
queue.put(bundle(sequence, size))
assert queue.bytes == 0
def test_release_uses_admitted_size_and_does_not_allow_reusing_owned_input():
queue = StreamMailbox()
first = bundle(0)
queue.put(first)
assert queue.take() is first
first.update(sequence=1, payload_bytes=1000)
with pytest.raises(ValueError, match="owned"):
queue.put(first)
with pytest.raises(ValueError, match="ownership"):
queue.release(dict(first))
queue.release(first)
assert queue.bytes == 0
with pytest.raises(ValueError, match="increase"):
queue.put(bundle(0))
def test_failed_gpu_releases_its_input_and_discards_pending():
started, proceed = threading.Event(), threading.Event()
queue = StreamMailbox()
def compute(_bundle):
started.set()
assert proceed.wait(1)
raise ValueError("synthetic failure")
stage = SerialGpuStage(queue, compute, threading.Event())
try:
queue.put(bundle(0))
assert started.wait(1)
queue.put(bundle(1))
proceed.set()
with pytest.raises(RuntimeError, match="synthetic failure"):
stage.take()
assert queue.quiescent and queue.bytes == 0
assert queue.drop_counts == {"gpu-failed": 2}
finally:
proceed.set()
assert stage.close()
def test_timed_out_stop_retains_ownership_until_callback_actually_exits():
started, proceed = threading.Event(), threading.Event()
queue = StreamMailbox()
def compute(_bundle):
started.set()
assert proceed.wait(1)
return "must not be published after cancellation"
stage = SerialGpuStage(queue, compute, threading.Event())
try:
queue.put(bundle(0))
assert started.wait(1)
assert not stage.close(timeout=0)
assert queue.bytes == 10 and not queue.quiescent
assert stage.take() is None
proceed.set()
assert stage.finished.wait(1)
assert stage.close() and queue.quiescent
assert queue.drop_counts == {"cancelled": 1}
finally:
proceed.set()
assert stage.close()
def test_stop_discards_completed_result_but_cannot_free_cpu_owned_input():
queue = StreamMailbox()
stage = SerialGpuStage(queue, lambda item: item["sequence"], threading.Event())
try:
queue.put(bundle(0))
first, _ = stage.take()
queue.put(bundle(1))
with stage.output_condition:
assert stage.output_condition.wait_for(lambda: stage.completed is not None, timeout=1)
assert queue.bytes == 20
assert stage.close()
assert queue.bytes == 10 and not queue.quiescent
queue.release(first)
assert queue.quiescent
finally:
assert stage.close()