feat(perception): trace M4.8S pipeline latency

This commit is contained in:
DCCONSTRUCTIONS
2026-08-25 18:21:57 +03:00
parent 5b07372791
commit a0152f371e
7 changed files with 453 additions and 9 deletions
@@ -19,9 +19,9 @@ from typing import Any, Final, TextIO, cast
import numpy as np import numpy as np
from k1link.perception.contracts import MotionState from k1link.perception.contracts import MotionState, ObjectProposal2D
from k1link.perception.contracts import ObjectProposal2D
from k1link.perception.detector import ( from k1link.perception.detector import (
DetectorFrameTiming,
DetectorProviderSnapshot, DetectorProviderSnapshot,
RfDetrShadowDetectorProvider, RfDetrShadowDetectorProvider,
) )
@@ -44,13 +44,15 @@ from k1link.perception.m48s_reference_graph_runtime import (
) )
from k1link.perception.motion import ClassIndependentMotionEstimator from k1link.perception.motion import ClassIndependentMotionEstimator
from k1link.perception.object_understanding import AdvisoryResponse from k1link.perception.object_understanding import AdvisoryResponse
from k1link.perception.reference_graph_runtime import ReferenceGraphRuntimePaths
from k1link.perception.providers import SourcePacket from k1link.perception.providers import SourcePacket
from k1link.perception.recorded_source import DecodedFrameTiming
from k1link.perception.reference_graph_runtime import ReferenceGraphRuntimePaths
from k1link.perception.rolling_map import RollingLocalObstacleMapProvider from k1link.perception.rolling_map import RollingLocalObstacleMapProvider
from k1link.perception.temporal import BoundedSpatialTemporalProvider from k1link.perception.temporal import BoundedSpatialTemporalProvider
SCHEMA_VERSION: Final = "missioncore.m48s-reference-graph-shadow-load/v0" SCHEMA_VERSION: Final = "missioncore.m48s-reference-graph-shadow-load/v1"
FRAME_EVIDENCE_SCHEMA: Final = "missioncore.m48s-reference-graph-frame-evidence/v0" FRAME_EVIDENCE_SCHEMA: Final = "missioncore.m48s-reference-graph-frame-evidence/v1"
PIPELINE_TIMING_SCHEMA: Final = "missioncore.m48s-frame-pipeline-timing/v0"
AUTHORITY: Final = { AUTHORITY: Final = {
"ground_truth": False, "ground_truth": False,
"candidate_accepted": False, "candidate_accepted": False,
@@ -104,6 +106,113 @@ class GpuTelemetry:
self._stop.wait(self.interval_seconds) self._stop.wait(self.interval_seconds)
class FrameTimingStore:
"""Join bounded decode, detector and provider timings by source sequence."""
def __init__(self) -> None:
self._lock = threading.Lock()
self._decode: dict[int, int] = {}
self._detector: dict[int, DetectorFrameTiming] = {}
self._providers: dict[int, dict[str, int]] = defaultdict(dict)
self._delivered: list[dict[str, object]] = []
def observe_decode(self, timing: DecodedFrameTiming) -> None:
with self._lock:
self._decode[timing.sequence] = timing.duration_ns
def observe_detector(self, timing: DetectorFrameTiming) -> None:
with self._lock:
self._detector[timing.sequence] = timing
def observe_provider(self, stage_id: str, sequence: int, duration_ns: int) -> None:
with self._lock:
stages = self._providers[sequence]
if stage_id in stages:
raise RuntimeError("provider timing stage was recorded twice")
stages[stage_id] = max(0, duration_ns)
def take(
self,
*,
sequence: int,
source_age_ns: int,
completion_age_ns: int,
) -> dict[str, object]:
with self._lock:
try:
decode_ns = self._decode.pop(sequence)
detector = self._detector.pop(sequence)
providers = self._providers.pop(sequence)
except KeyError as exc:
raise RuntimeError("delivered frame pipeline timing is incomplete") from exc
expected_stages = {"geometry", "temporal", "motion", "rolling", "threat"}
if set(providers) != expected_stages:
raise RuntimeError("delivered frame provider timing stages are incomplete")
admission_to_delivery_ns = max(0, completion_age_ns - source_age_ns)
provider_ns = sum(providers.values())
attributed_graph_ns = detector.total_duration_ns + provider_ns
unattributed_ns = max(0, admission_to_delivery_ns - attributed_graph_ns)
document = {
"schema_version": PIPELINE_TIMING_SCHEMA,
"sequence": sequence,
"decode_duration_ns": decode_ns,
"detector": detector.to_dict(),
"providers": dict(sorted(providers.items())),
"graph_admission_to_delivery_ns": admission_to_delivery_ns,
"graph_attributed_provider_ns": attributed_graph_ns,
"graph_unattributed_ns": unattributed_ns,
"decode_to_delivery_processing_ns": decode_ns + admission_to_delivery_ns,
}
with self._lock:
self._delivered.append(document)
return document
def delivered(self) -> tuple[dict[str, object], ...]:
with self._lock:
return tuple(self._delivered)
class TimedProviderProxy:
"""Record one provider's actual call duration without another inference pass."""
def __init__(self, stage_id: str, provider: object, store: FrameTimingStore) -> None:
self.stage_id = stage_id
self.provider = provider
self.store = store
self.provider_id = cast(Any, provider).provider_id
def _call(self, sequence: int, method: str, *args: object) -> object:
started_ns = time.perf_counter_ns()
try:
return getattr(self.provider, method)(*args)
finally:
self.store.observe_provider(
self.stage_id,
sequence,
max(0, time.perf_counter_ns() - started_ns),
)
def associate(self, packet: SourcePacket, proposals: object) -> object:
return self._call(packet.envelope.sequence, "associate", packet, proposals)
def update(self, packet: SourcePacket, values: object) -> object:
return self._call(packet.envelope.sequence, "update", packet, values)
def estimate(self, packet: SourcePacket, obstacles: object) -> object:
return self._call(packet.envelope.sequence, "estimate", packet, obstacles)
def assess(self, obstacle_map: object) -> object:
frame_id = cast(Any, obstacle_map).frame_id
try:
sequence = int(str(frame_id).rsplit("-", 1)[1])
except (IndexError, ValueError) as exc:
raise RuntimeError("threat timing frame id is incompatible") from exc
return self._call(sequence, "assess", obstacle_map)
def snapshot(self) -> object:
return cast(Any, self.provider).snapshot()
def main() -> int: def main() -> int:
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
for name in ( for name in (
@@ -161,6 +270,7 @@ def main() -> int:
completion_ages_ms: list[float] = [] completion_ages_ms: list[float] = []
map_output_ages_ms: list[float] = [] map_output_ages_ms: list[float] = []
all_deliveries: list[DeliveredFrame] = [] all_deliveries: list[DeliveredFrame] = []
all_pipeline_timings: list[dict[str, object]] = []
with ( with (
progress.open("x", encoding="utf-8") as progress_stream, progress.open("x", encoding="utf-8") as progress_stream,
frame_ledger.open("x", encoding="utf-8") as frame_ledger_stream, frame_ledger.open("x", encoding="utf-8") as frame_ledger_stream,
@@ -168,6 +278,7 @@ def main() -> int:
): ):
for loop_index in range(arguments.loops): for loop_index in range(arguments.loops):
loop_completion_ages_ns: list[int] = [] loop_completion_ages_ns: list[int] = []
timing_store = FrameTimingStore()
setup_started_ns = time.monotonic_ns() setup_started_ns = time.monotonic_ns()
with build_m48s_reference_graph_runtime( with build_m48s_reference_graph_runtime(
@@ -183,9 +294,28 @@ def main() -> int:
_record_delivery_evidence, _record_delivery_evidence,
frame_ledger_stream, frame_ledger_stream,
loop_index, loop_index,
timing_store,
), ),
decode_timing_observer=timing_store.observe_decode,
detector_timing_observer=timing_store.observe_detector,
maximum_frames=arguments.maximum_frames, maximum_frames=arguments.maximum_frames,
) as runtime: ) as runtime:
for stage_id, attribute in (
("geometry", "geometry"),
("temporal", "temporal"),
("motion", "motion"),
("rolling", "rolling"),
("threat", "threat"),
):
setattr(
runtime.graph,
attribute,
TimedProviderProxy(
stage_id,
getattr(runtime.graph, attribute),
timing_store,
),
)
loop_started_ns = time.monotonic_ns() loop_started_ns = time.monotonic_ns()
result = runtime.graph.run() result = runtime.graph.run()
loop_completed_ns = time.monotonic_ns() loop_completed_ns = time.monotonic_ns()
@@ -225,6 +355,9 @@ def main() -> int:
setup_seconds = (loop_started_ns - setup_started_ns) / 1_000_000_000.0 setup_seconds = (loop_started_ns - setup_started_ns) / 1_000_000_000.0
if len(loop_completion_ages_ns) != len(result.deliveries): if len(loop_completion_ages_ns) != len(result.deliveries):
raise RuntimeError("final delivery timing accounting did not close") raise RuntimeError("final delivery timing accounting did not close")
loop_pipeline_timings = timing_store.delivered()
if len(loop_pipeline_timings) != len(result.deliveries):
raise RuntimeError("pipeline timing accounting did not close")
loop_document = _loop_document( loop_document = _loop_document(
loop_index=loop_index, loop_index=loop_index,
result=result, result=result,
@@ -241,6 +374,7 @@ def main() -> int:
for delivery in result.deliveries for delivery in result.deliveries
) )
all_deliveries.extend(result.deliveries) all_deliveries.extend(result.deliveries)
all_pipeline_timings.extend(loop_pipeline_timings)
frame_ledger_stream.flush() frame_ledger_stream.flush()
progress_row = { progress_row = {
"loop": loop_index + 1, "loop": loop_index + 1,
@@ -313,6 +447,7 @@ def main() -> int:
"conservative_unknown_motion_advisory": _unknown_motion_is_conservative(advisories), "conservative_unknown_motion_advisory": _unknown_motion_is_conservative(advisories),
"distinct_class_family_policy": len(set(advisory_policy_matrix().values())) "distinct_class_family_policy": len(set(advisory_policy_matrix().values()))
== len(AdvisoryFamily), == len(AdvisoryFamily),
"complete_pipeline_timing": len(all_pipeline_timings) == delivered,
"authority_remains_false": all(value is False for value in AUTHORITY.values()), "authority_remains_false": all(value is False for value in AUTHORITY.values()),
} }
integrated_runtime_gate_passed = all(checks.values()) integrated_runtime_gate_passed = all(checks.values())
@@ -351,6 +486,7 @@ def main() -> int:
"local_obstacle_map_output_age_ms": _distribution(map_output_ages_ms), "local_obstacle_map_output_age_ms": _distribution(map_output_ages_ms),
"identity_continuity": identity, "identity_continuity": identity,
"semantic_advisory": semantic, "semantic_advisory": semantic,
"pipeline_timing": _pipeline_timing_metrics(all_pipeline_timings),
"gpu": _telemetry_summary(gpu.samples), "gpu": _telemetry_summary(gpu.samples),
"process_peak_rss_before_mib": round(rss_before_kib / 1024.0, 6), "process_peak_rss_before_mib": round(rss_before_kib / 1024.0, 6),
"process_peak_rss_after_mib": round(rss_after_kib / 1024.0, 6), "process_peak_rss_after_mib": round(rss_after_kib / 1024.0, 6),
@@ -382,6 +518,7 @@ def _record_completion_age(
def _record_delivery_evidence( def _record_delivery_evidence(
stream: TextIO, stream: TextIO,
loop_index: int, loop_index: int,
timing_store: FrameTimingStore,
delivery: DeliveredFrame, delivery: DeliveredFrame,
packet: SourcePacket, packet: SourcePacket,
proposals: tuple[ObjectProposal2D, ...], proposals: tuple[ObjectProposal2D, ...],
@@ -396,6 +533,11 @@ def _record_delivery_evidence(
"source_envelope": packet.envelope.to_dict(), "source_envelope": packet.envelope.to_dict(),
"completion_age_ns": completion_age_ns, "completion_age_ns": completion_age_ns,
"local_obstacle_map_output_age_ns": delivery.obstacle_map.output_age_ns, "local_obstacle_map_output_age_ns": delivery.obstacle_map.output_age_ns,
"pipeline_timing": timing_store.take(
sequence=delivery.sequence,
source_age_ns=packet.envelope.source_age_ns,
completion_age_ns=completion_age_ns,
),
"delivery": delivery.canonical_dict(), "delivery": delivery.canonical_dict(),
"detector_proposals": [proposal.to_dict() for proposal in proposals], "detector_proposals": [proposal.to_dict() for proposal in proposals],
"associated_proposal_ids": sorted(associated_proposal_ids), "associated_proposal_ids": sorted(associated_proposal_ids),
@@ -536,6 +678,67 @@ def _distribution(values: list[float]) -> dict[str, float]:
} }
def _pipeline_timing_metrics(
documents: list[dict[str, object]],
) -> dict[str, object]:
detector_fields = (
"preprocess_duration_ns",
"inference_transport_duration_ns",
"postprocess_duration_ns",
"total_duration_ns",
)
provider_fields = ("geometry", "temporal", "motion", "rolling", "threat")
detector_values: dict[str, list[float]] = {key: [] for key in detector_fields}
provider_values: dict[str, list[float]] = {key: [] for key in provider_fields}
top_level_fields = (
"decode_duration_ns",
"graph_admission_to_delivery_ns",
"graph_attributed_provider_ns",
"graph_unattributed_ns",
"decode_to_delivery_processing_ns",
)
top_level_values: dict[str, list[float]] = {key: [] for key in top_level_fields}
for document in documents:
detector = cast(Mapping[str, int], document["detector"])
providers = cast(Mapping[str, int], document["providers"])
for key in detector_fields:
detector_values[key].append(detector[key] / 1_000_000.0)
for key in provider_fields:
provider_values[key].append(providers[key] / 1_000_000.0)
for key in top_level_fields:
top_level_values[key].append(cast(int, document[key]) / 1_000_000.0)
maximum = max(
documents,
key=lambda document: cast(int, document["graph_admission_to_delivery_ns"]),
default=None,
)
return {
"sample_count": len(documents),
"decode_duration_ms": _distribution(top_level_values["decode_duration_ns"]),
"detector_ms": {
key.removesuffix("_duration_ns"): _distribution(values)
for key, values in detector_values.items()
},
"provider_ms": {
key: _distribution(values) for key, values in provider_values.items()
},
"graph_admission_to_delivery_ms": _distribution(
top_level_values["graph_admission_to_delivery_ns"]
),
"graph_attributed_provider_ms": _distribution(
top_level_values["graph_attributed_provider_ns"]
),
"graph_unattributed_ms": _distribution(top_level_values["graph_unattributed_ns"]),
"decode_to_delivery_processing_ms": _distribution(
top_level_values["decode_to_delivery_processing_ns"]
),
"maximum_graph_sequence": (
cast(int, maximum["sequence"]) if maximum is not None else None
),
"additional_inference_passes": 0,
}
def _telemetry_summary(samples: list[dict[str, float]]) -> dict[str, Any]: def _telemetry_summary(samples: list[dict[str, float]]) -> dict[str, Any]:
result: dict[str, Any] = {"sample_count": len(samples)} result: dict[str, Any] = {"sample_count": len(samples)}
for key in ( for key in (
+61 -1
View File
@@ -62,6 +62,45 @@ class DetectorProviderSnapshot:
core_duration_ns: int core_duration_ns: int
@dataclass(frozen=True, slots=True)
class DetectorFrameTiming:
sequence: int
preprocess_duration_ns: int
inference_transport_duration_ns: int
postprocess_duration_ns: int
total_duration_ns: int
def __post_init__(self) -> None:
values = (
self.sequence,
self.preprocess_duration_ns,
self.inference_transport_duration_ns,
self.postprocess_duration_ns,
self.total_duration_ns,
)
if any(value < 0 for value in values):
raise DetectorProviderError("detector frame timing must be nonnegative")
if (
self.preprocess_duration_ns
+ self.inference_transport_duration_ns
+ self.postprocess_duration_ns
!= self.total_duration_ns
):
raise DetectorProviderError("detector frame timing does not close")
def to_dict(self) -> dict[str, int]:
return {
"sequence": self.sequence,
"preprocess_duration_ns": self.preprocess_duration_ns,
"inference_transport_duration_ns": self.inference_transport_duration_ns,
"postprocess_duration_ns": self.postprocess_duration_ns,
"total_duration_ns": self.total_duration_ns,
}
DetectorTimingObserver = Callable[[DetectorFrameTiming], None]
class FrozenYoloxDetectorProvider: class FrozenYoloxDetectorProvider:
"""One image payload produces one frozen inference request and proposal tuple.""" """One image payload produces one frozen inference request and proposal tuple."""
@@ -202,6 +241,7 @@ class RfDetrShadowDetectorProvider:
resizer: ImageResizer | None = None, resizer: ImageResizer | None = None,
config: RfDetrConfig = RF_DETR_CONFIG, config: RfDetrConfig = RF_DETR_CONFIG,
clock_ns: Callable[[], int] = time.perf_counter_ns, clock_ns: Callable[[], int] = time.perf_counter_ns,
timing_observer: DetectorTimingObserver | None = None,
) -> None: ) -> None:
if mask.shape != (600, 800) or mask.dtype != np.bool_ or not np.any(mask): if mask.shape != (600, 800) or mask.dtype != np.bool_ or not np.any(mask):
raise DetectorProviderError("RF-DETR valid-FOV mask is incompatible") raise DetectorProviderError("RF-DETR valid-FOV mask is incompatible")
@@ -210,6 +250,7 @@ class RfDetrShadowDetectorProvider:
self.resizer = resizer self.resizer = resizer
self.config = config self.config = config
self._clock_ns = clock_ns self._clock_ns = clock_ns
self.timing_observer = timing_observer
self._lock = Lock() self._lock = Lock()
self._input_frames = 0 self._input_frames = 0
self._completed_frames = 0 self._completed_frames = 0
@@ -236,7 +277,13 @@ class RfDetrShadowDetectorProvider:
config=self.config, config=self.config,
resizer=self.resizer, resizer=self.resizer,
) )
preprocessed_ns = (
int(self._clock_ns()) if self.timing_observer is not None else started_ns
)
output = self.backend.infer(tensor) output = self.backend.infer(tensor)
inferred_ns = (
int(self._clock_ns()) if self.timing_observer is not None else preprocessed_ns
)
postprocessed = postprocess_rf_detr(output, self.mask, config=self.config) postprocessed = postprocess_rf_detr(output, self.mask, config=self.config)
proposals = proposals_from_rf_detr_detections(packet, postprocessed.detections) proposals = proposals_from_rf_detr_detections(packet, postprocessed.detections)
except Exception: except Exception:
@@ -244,12 +291,23 @@ class RfDetrShadowDetectorProvider:
self._failed_frames += 1 self._failed_frames += 1
self._core_duration_ns += max(0, int(self._clock_ns()) - started_ns) self._core_duration_ns += max(0, int(self._clock_ns()) - started_ns)
raise raise
completed_ns = int(self._clock_ns())
with self._lock: with self._lock:
self._completed_frames += 1 self._completed_frames += 1
self._proposal_count += len(proposals) self._proposal_count += len(proposals)
self._zero_proposal_frames += not proposals self._zero_proposal_frames += not proposals
self._rejected.update(dict(postprocessed.rejected)) self._rejected.update(dict(postprocessed.rejected))
self._core_duration_ns += max(0, int(self._clock_ns()) - started_ns) self._core_duration_ns += max(0, completed_ns - started_ns)
if self.timing_observer is not None:
self.timing_observer(
DetectorFrameTiming(
sequence=packet.envelope.sequence,
preprocess_duration_ns=max(0, preprocessed_ns - started_ns),
inference_transport_duration_ns=max(0, inferred_ns - preprocessed_ns),
postprocess_duration_ns=max(0, completed_ns - inferred_ns),
total_duration_ns=max(0, completed_ns - started_ns),
)
)
return proposals return proposals
def snapshot(self) -> DetectorProviderSnapshot: def snapshot(self) -> DetectorProviderSnapshot:
@@ -297,6 +355,8 @@ __all__ = [
"RF_DETR_SHADOW_PROVIDER_ID", "RF_DETR_SHADOW_PROVIDER_ID",
"DetectorProviderError", "DetectorProviderError",
"DetectorProviderSnapshot", "DetectorProviderSnapshot",
"DetectorFrameTiming",
"DetectorTimingObserver",
"AllCocoYoloxDetectorProvider", "AllCocoYoloxDetectorProvider",
"FrozenYoloxDetectorProvider", "FrozenYoloxDetectorProvider",
"RfDetrShadowDetectorProvider", "RfDetrShadowDetectorProvider",
@@ -10,7 +10,11 @@ from pathlib import Path
from threading import Event from threading import Event
from .baseline import load_m4_baseline from .baseline import load_m4_baseline
from .detector import RF_DETR_SHADOW_PROVIDER_ID, RfDetrShadowDetectorProvider from .detector import (
RF_DETR_SHADOW_PROVIDER_ID,
DetectorTimingObserver,
RfDetrShadowDetectorProvider,
)
from .geometry import ( from .geometry import (
Ravnoves00GeometryAssociationProvider, Ravnoves00GeometryAssociationProvider,
RecordedGeometryStore, RecordedGeometryStore,
@@ -27,6 +31,7 @@ from .providers import (
) )
from .recorded_source import ( from .recorded_source import (
DecodedRecordedSource, DecodedRecordedSource,
DecodeTimingObserver,
PyAvRecordedImageDecoder, PyAvRecordedImageDecoder,
RecordedRavnoves00Source, RecordedRavnoves00Source,
ReplayPacing, ReplayPacing,
@@ -77,6 +82,8 @@ def build_m48s_reference_graph_runtime(
run_mode: GraphRunMode, run_mode: GraphRunMode,
delivery_observer: Callable[[DeliveredFrame, int], None] | None = None, delivery_observer: Callable[[DeliveredFrame, int], None] | None = None,
delivery_evidence_observer: DeliveryEvidenceObserver | None = None, delivery_evidence_observer: DeliveryEvidenceObserver | None = None,
decode_timing_observer: DecodeTimingObserver | None = None,
detector_timing_observer: DetectorTimingObserver | None = None,
maximum_frames: int | None = None, maximum_frames: int | None = None,
) -> M48sReferenceGraphRuntime: ) -> M48sReferenceGraphRuntime:
"""Instantiate the complete graph with only its detector pin replaced.""" """Instantiate the complete graph with only its detector pin replaced."""
@@ -113,6 +120,7 @@ def build_m48s_reference_graph_runtime(
), ),
), ),
decoder=PyAvRecordedImageDecoder(paths.video), decoder=PyAvRecordedImageDecoder(paths.video),
timing_observer=decode_timing_observer,
) )
if maximum_frames is not None: if maximum_frames is not None:
source = _LimitedSource(source, maximum_frames) source = _LimitedSource(source, maximum_frames)
@@ -133,6 +141,7 @@ def build_m48s_reference_graph_runtime(
detector=RfDetrShadowDetectorProvider( detector=RfDetrShadowDetectorProvider(
mask=load_valid_fov_mask(paths.valid_fov_mask), mask=load_valid_fov_mask(paths.valid_fov_mask),
backend=backend, backend=backend,
timing_observer=detector_timing_observer,
), ),
geometry=Ravnoves00GeometryAssociationProvider(store=store), geometry=Ravnoves00GeometryAssociationProvider(store=store),
temporal=BoundedSpatialTemporalProvider( temporal=BoundedSpatialTemporalProvider(
+39 -2
View File
@@ -85,6 +85,19 @@ class RecordedImageDecoder(Protocol):
WaitFunction = Callable[[Event, float], bool] WaitFunction = Callable[[Event, float], bool]
@dataclass(frozen=True, slots=True)
class DecodedFrameTiming:
sequence: int
duration_ns: int
def __post_init__(self) -> None:
if self.sequence < 0 or self.duration_ns < 0:
raise RecordedSourceError("decoded frame timing must be nonnegative")
DecodeTimingObserver = Callable[[DecodedFrameTiming], None]
class RecordedRavnoves00Source: class RecordedRavnoves00Source:
"""Emit the admitted synchronized source timeline at 1.0x or uncapped speed.""" """Emit the admitted synchronized source timeline at 1.0x or uncapped speed."""
@@ -182,14 +195,18 @@ class DecodedRecordedSource:
*, *,
source: RecordedRavnoves00Source, source: RecordedRavnoves00Source,
decoder: RecordedImageDecoder, decoder: RecordedImageDecoder,
timing_observer: DecodeTimingObserver | None = None,
clock_ns: Callable[[], int] = time.perf_counter_ns,
) -> None: ) -> None:
self.source = source self.source = source
self.decoder = decoder self.decoder = decoder
self.timing_observer = timing_observer
self._clock_ns = clock_ns
def packets(self, stop_event: Event) -> Iterator[SourcePacket]: def packets(self, stop_event: Event) -> Iterator[SourcePacket]:
images = self.decoder.frames(stop_event) images = self.decoder.frames(stop_event)
try: try:
image: NDArray[np.uint8] | None = next(images) image: NDArray[np.uint8] | None = self._next_image(images, 0)
except StopIteration as exc: except StopIteration as exc:
if stop_event.is_set(): if stop_event.is_set():
return return
@@ -203,7 +220,7 @@ class DecodedRecordedSource:
raise RecordedSourceError("decoded image raster is incompatible") raise RecordedSourceError("decoded image raster is incompatible")
yield replace(packet, image_payload=image) yield replace(packet, image_payload=image)
try: try:
image = next(images) image = self._next_image(images, packet.envelope.sequence + 1)
except StopIteration: except StopIteration:
image = None image = None
if not stop_event.is_set(): if not stop_event.is_set():
@@ -211,6 +228,24 @@ class DecodedRecordedSource:
return return
raise RecordedSourceError("decoded image stream exceeds source timeline") raise RecordedSourceError("decoded image stream exceeds source timeline")
def _next_image(
self,
images: Iterator[NDArray[np.uint8]],
sequence: int,
) -> NDArray[np.uint8]:
observer = self.timing_observer
if observer is None:
return next(images)
started_ns = int(self._clock_ns())
image = next(images)
observer(
DecodedFrameTiming(
sequence=sequence,
duration_ns=max(0, int(self._clock_ns()) - started_ns),
)
)
return image
class PyAvRecordedImageDecoder: class PyAvRecordedImageDecoder:
"""Sequential full-video decoder used by the Worker 006 recorded source adapter.""" """Sequential full-video decoder used by the Worker 006 recorded source adapter."""
@@ -394,6 +429,8 @@ def _event_wait(stop_event: Event, timeout_seconds: float) -> bool:
__all__ = [ __all__ = [
"BASELINE_PROFILE_ID", "BASELINE_PROFILE_ID",
"DecodeTimingObserver",
"DecodedFrameTiming",
"LiveSourceAdapter", "LiveSourceAdapter",
"DecodedRecordedSource", "DecodedRecordedSource",
"PyAvRecordedImageDecoder", "PyAvRecordedImageDecoder",
+83
View File
@@ -0,0 +1,83 @@
from __future__ import annotations
import importlib.util
from pathlib import Path
from k1link.perception.detector import DetectorFrameTiming
from k1link.perception.recorded_source import DecodedFrameTiming
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
RUNNER_PATH = (
REPOSITORY_ROOT / "experiments/perception/run_m48s_reference_graph_shadow_worker.py"
)
SPEC = importlib.util.spec_from_file_location("m48s_timed_shadow_runner", RUNNER_PATH)
assert SPEC is not None and SPEC.loader is not None
RUNNER = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(RUNNER)
def test_frame_timing_store_closes_provider_and_unattributed_time() -> None:
store = RUNNER.FrameTimingStore()
store.observe_decode(DecodedFrameTiming(sequence=7, duration_ns=5))
store.observe_detector(
DetectorFrameTiming(
sequence=7,
preprocess_duration_ns=10,
inference_transport_duration_ns=20,
postprocess_duration_ns=30,
total_duration_ns=60,
)
)
for stage_id, duration_ns in (
("geometry", 1),
("temporal", 2),
("motion", 3),
("rolling", 4),
("threat", 5),
):
store.observe_provider(stage_id, 7, duration_ns)
document = store.take(sequence=7, source_age_ns=100, completion_age_ns=300)
assert document["schema_version"] == RUNNER.PIPELINE_TIMING_SCHEMA
assert document["graph_admission_to_delivery_ns"] == 200
assert document["graph_attributed_provider_ns"] == 75
assert document["graph_unattributed_ns"] == 125
assert document["decode_to_delivery_processing_ns"] == 205
assert store.delivered() == (document,)
def test_pipeline_timing_metrics_preserve_single_pass_stage_breakdown() -> None:
store = RUNNER.FrameTimingStore()
store.observe_decode(DecodedFrameTiming(sequence=3, duration_ns=1_000_000))
store.observe_detector(
DetectorFrameTiming(
sequence=3,
preprocess_duration_ns=2_000_000,
inference_transport_duration_ns=3_000_000,
postprocess_duration_ns=4_000_000,
total_duration_ns=9_000_000,
)
)
for stage_id, duration_ns in (
("geometry", 1_000_000),
("temporal", 2_000_000),
("motion", 3_000_000),
("rolling", 4_000_000),
("threat", 5_000_000),
):
store.observe_provider(stage_id, 3, duration_ns)
document = store.take(
sequence=3,
source_age_ns=0,
completion_age_ns=30_000_000,
)
metrics = RUNNER._pipeline_timing_metrics([document])
assert metrics["sample_count"] == 1
assert metrics["detector_ms"]["inference_transport"]["maximum"] == 3.0
assert metrics["provider_ms"]["threat"]["maximum"] == 5.0
assert metrics["graph_unattributed_ms"]["maximum"] == 6.0
assert metrics["maximum_graph_sequence"] == 3
assert metrics["additional_inference_passes"] == 0
+29
View File
@@ -60,6 +60,7 @@ from k1link.perception.providers import (
SourcePacket, SourcePacket,
) )
from k1link.perception.recorded_source import ( from k1link.perception.recorded_source import (
DecodedFrameTiming,
DecodedRecordedSource, DecodedRecordedSource,
RecordedRavnoves00Source, RecordedRavnoves00Source,
RecordedSourceError, RecordedSourceError,
@@ -869,6 +870,34 @@ def test_decoded_recorded_source_attaches_images_without_detector_logic(tmp_path
assert int(packets[1].image_payload[0, 0, 0]) == 7 assert int(packets[1].image_payload[0, 0, 0]) == 7
def test_decoded_recorded_source_reports_per_frame_decode_timing(tmp_path: Path) -> None:
camera_path, timeline_path = _write_recorded_fixture(tmp_path)
source = RecordedRavnoves00Source(
camera_index_path=camera_path,
source_pack_path=timeline_path,
expected_frame_count=2,
expected_source_pack_sha256=None,
)
observed: list[DecodedFrameTiming] = []
class Decoder:
def frames(self, _stop_event: Event) -> Iterator[np.ndarray]:
yield np.zeros((600, 800, 3), dtype=np.uint8)
yield np.zeros((600, 800, 3), dtype=np.uint8)
packets = list(
DecodedRecordedSource(
source=source,
decoder=Decoder(),
timing_observer=observed.append,
clock_ns=iter((10, 30, 40, 90, 100)).__next__,
).packets(Event())
)
assert len(packets) == 2
assert observed == [DecodedFrameTiming(0, 20), DecodedFrameTiming(1, 50)]
def test_decoded_recorded_source_primes_decoder_before_source_clock() -> None: def test_decoded_recorded_source_primes_decoder_before_source_clock() -> None:
events: list[str] = [] events: list[str] = []
+23
View File
@@ -20,6 +20,7 @@ from k1link.perception.detector import (
RF_DETR_SHADOW_MODEL_ID, RF_DETR_SHADOW_MODEL_ID,
RF_DETR_SHADOW_PREPROCESS_ID, RF_DETR_SHADOW_PREPROCESS_ID,
RF_DETR_SHADOW_PROVIDER_ID, RF_DETR_SHADOW_PROVIDER_ID,
DetectorFrameTiming,
RfDetrShadowDetectorProvider, RfDetrShadowDetectorProvider,
) )
from k1link.perception.providers import SourcePacket from k1link.perception.providers import SourcePacket
@@ -170,6 +171,28 @@ def test_shadow_provider_uses_one_pass_and_preserves_semantic_hints() -> None:
assert provider.snapshot().core_duration_ns == 20 assert provider.snapshot().core_duration_ns == 20
def test_shadow_provider_reports_preprocess_transport_and_postprocess_timing() -> None:
observed: list[DetectorFrameTiming] = []
provider = RfDetrShadowDetectorProvider(
mask=np.ones((600, 800), dtype=np.bool_),
backend=_Backend(_output()),
resizer=_Resizer(),
clock_ns=iter((10, 20, 50, 70)).__next__,
timing_observer=observed.append,
)
provider.detect(_packet(7, np.zeros((600, 800, 3), dtype=np.uint8)))
assert len(observed) == 1
assert observed[0].to_dict() == {
"sequence": 7,
"preprocess_duration_ns": 10,
"inference_transport_duration_ns": 30,
"postprocess_duration_ns": 20,
"total_duration_ns": 60,
}
def test_shadow_profile_is_fixed_and_transport_pins_model_version() -> None: def test_shadow_profile_is_fixed_and_transport_pins_model_version() -> None:
assert RF_DETR_CONFIG.minimum_score == 0.25 assert RF_DETR_CONFIG.minimum_score == 0.25
with pytest.raises(RfDetrDetectorError, match="cannot be tuned"): with pytest.raises(RfDetrDetectorError, match="cannot be tuned"):