Refactor radar system

This commit is contained in:
Ayzen
2026-10-05 16:46:28 +03:00
parent 515049d2ed
commit 8f097dfa2e
179 changed files with 8212 additions and 6676 deletions
+66
View File
@@ -0,0 +1,66 @@
"""GCC atomic operations on aligned shared-memory fields.
The libatomic ABI omits the builtin compare-exchange ``weak`` argument:
https://github.com/gcc-mirror/gcc/blob/master/libatomic/cas_n.c
"""
from __future__ import annotations
import ctypes
from functools import cache
import mmap
_ACQUIRE, _RELEASE, _ACQ_REL, _SEQ_CST = 2, 3, 4, 5
@cache
def _library() -> ctypes.CDLL:
try:
library = ctypes.CDLL("libatomic.so.1")
except OSError as exc:
raise RuntimeError("Shared memory requires libatomic.so.1 (install libatomic1)") from exc
library.__atomic_is_lock_free.argtypes = [ctypes.c_size_t, ctypes.c_void_p]
library.__atomic_is_lock_free.restype = ctypes.c_bool
for width, number in ((4, ctypes.c_uint32), (8, ctypes.c_uint64)):
if not library.__atomic_is_lock_free(width, None):
raise RuntimeError(f"Shared memory requires lock-free {width}-byte atomics")
load = getattr(library, f"__atomic_load_{width}")
load.argtypes, load.restype = [ctypes.c_void_p, ctypes.c_int], number
store = getattr(library, f"__atomic_store_{width}")
store.argtypes, store.restype = [ctypes.c_void_p, number, ctypes.c_int], None
library.__atomic_compare_exchange_8.argtypes = [
ctypes.c_void_p, ctypes.POINTER(ctypes.c_uint64), ctypes.c_uint64, ctypes.c_int, ctypes.c_int,
]
library.__atomic_compare_exchange_8.restype = ctypes.c_bool
library.atomic_thread_fence.argtypes, library.atomic_thread_fence.restype = [ctypes.c_int], None
return library
def _address(buffer: mmap.mmap, offset: int, width: int) -> int:
if offset % width or offset < 0 or offset + width > len(buffer):
raise ValueError("Atomic field must be aligned and inside the mapping")
return ctypes.addressof(ctypes.c_char.from_buffer(buffer, offset))
def load(buffer: mmap.mmap, offset: int, width: int) -> int:
return getattr(_library(), f"__atomic_load_{width}")(_address(buffer, offset, width), _ACQUIRE)
def store(buffer: mmap.mmap, offset: int, value: int, width: int) -> None:
if not 0 <= value < 1 << (8 * width):
raise ValueError("Atomic field value exceeds its unsigned wire range")
getattr(_library(), f"__atomic_store_{width}")(_address(buffer, offset, width), value, _RELEASE)
def advance(buffer: mmap.mmap, offset: int, desired: int) -> None:
"""Advance a shared cursor with CAS; concurrent progress can never be undone."""
address = _address(buffer, offset, 8)
expected = ctypes.c_uint64(load(buffer, offset, 8))
while expected.value < desired:
if _library().__atomic_compare_exchange_8(address, ctypes.byref(expected), desired, _ACQ_REL, _ACQUIRE):
break
def fence() -> None:
"""Order invalidation/copy/validation on CPUs with weak memory ordering."""
_library().atomic_thread_fence(_SEQ_CST)
+4 -6
View File
@@ -119,7 +119,7 @@ def decode_result_collection(payload: bytes) -> ResultCollection:
image_y_axis = np.frombuffer(cursor.read_bytes(y_count * 4), dtype="<f4").astype(np.float32, copy=False)
value_count = x_count * y_count
image_values = np.frombuffer(cursor.read_bytes(value_count * 4), dtype="<f4").astype(np.float32, copy=False)
image = image_values.reshape((y_count, x_count)) if value_count > 0 else np.zeros((0, 0), dtype=np.float32)
image = image_values.reshape((y_count, x_count))
return ResultPayload(
processing_name=name,
kind=kind,
@@ -130,13 +130,11 @@ def decode_result_collection(payload: bytes) -> ResultCollection:
if kind == 4:
table_columns = cursor.read_u32()
table_rows = cursor.read_u32()
if table_rows and not table_columns:
raise ValueError("Result table with rows must have at least one column")
value_count = table_columns * table_rows
table_values = np.frombuffer(cursor.read_bytes(value_count * 4), dtype="<f4").astype(np.float32, copy=False)
table = (
table_values.reshape((table_rows, table_columns))
if value_count > 0 and table_columns > 0
else np.zeros((0, 0), dtype=np.float32)
)
table = table_values.reshape((table_rows, table_columns))
return ResultPayload(
processing_name=name,
kind=kind,
+30 -28
View File
@@ -5,11 +5,11 @@ from __future__ import annotations
import logging
import mmap
from pathlib import Path
import struct
import time
from typing import Final
from python_app.models.dataset_model import ResultCollection, SweepCollection
from python_app.orchestration.shm import atomics
from python_app.orchestration.shm.decoder import (
PREPROC_MAGIC,
RAW_MAGIC,
@@ -30,8 +30,8 @@ class ShmRingReader:
def __init__(self, ring_name: str, open_timeout_s: float = 2.0, open_poll_s: float = 0.01) -> None:
"""Open and validate ring by name, for example `/radar_results`."""
if not ring_name.startswith("/"):
raise ValueError("ring_name must start with '/'")
if not ring_name.startswith("/") or len(ring_name) < 2 or "/" in ring_name[1:] or "\x00" in ring_name:
raise ValueError("ring_name must be '/' followed by a single nonempty filename")
self._ring_name = ring_name
self._path = Path("/dev/shm") / ring_name[1:]
@@ -79,7 +79,7 @@ class ShmRingReader:
if sequence != read_seq + 1:
# Producer overwrote this slot before we read it. Resync to latest.
logger.debug("Ring %s: slot lapped before read, resyncing to write_seq=%d", self._ring_name, write_seq)
self._write_u64(32, write_seq)
atomics.advance(self._mmap, 32, write_seq)
return None
payload_size = self._read_u32(slot_offset)
@@ -92,20 +92,21 @@ class ShmRingReader:
payload_size,
self.slot_size_bytes,
)
self._write_u64(32, write_seq)
atomics.advance(self._mmap, 32, write_seq)
return None
payload_offset = slot_offset + _SLOT_HEADER_SIZE
payload = bytes(self._mmap[payload_offset : payload_offset + payload_size])
atomics.fence()
# Re-read the slot sequence after the copy; if it changed, the producer
# overwrote this slot mid-copy and the payload is torn — discard and resync.
if self._read_u64(slot_offset + 8) != read_seq + 1:
logger.debug("Ring %s: slot overwritten mid-copy, discarding torn payload", self._ring_name)
self._write_u64(32, write_seq)
atomics.advance(self._mmap, 32, write_seq)
return None
self._write_u64(32, read_seq + 1)
atomics.advance(self._mmap, 32, read_seq + 1)
return payload
def pop_raw_collection(self) -> SweepCollection | None:
@@ -157,17 +158,11 @@ class ShmRingReader:
return None
payload_offset = slot_offset + _SLOT_HEADER_SIZE
payload = bytes(self._mmap[payload_offset : payload_offset + payload_size])
atomics.fence()
if self._read_u64(slot_offset + 8) != latest_seq + 1:
return None
return payload
def peek_latest_result_collection(self) -> ResultCollection | None:
"""Return the most recently published result collection without consuming it."""
payload = self.peek_latest_payload()
if payload is None:
return None
return decode_result_collection(payload)
def drop_all(self) -> int:
"""Mark all unread slots as consumed and return number of dropped payloads."""
write_seq = self._read_u64(24)
@@ -175,7 +170,7 @@ class ShmRingReader:
if read_seq >= write_seq:
return 0
dropped = int(write_seq - read_seq)
self._write_u64(32, write_seq)
atomics.advance(self._mmap, 32, write_seq)
logger.debug("Ring %s: dropped %d unread payload(s)", self._ring_name, dropped)
return dropped
@@ -196,7 +191,11 @@ class ShmRingReader:
magic = self._mmap[:8]
version = self._read_u32(8)
if magic == _MAGIC and version == _VERSION:
return
capacity = self.capacity
slot_size = self.slot_size_bytes
expected_size = _HEADER_SIZE + capacity * (_SLOT_HEADER_SIZE + slot_size)
if capacity > 0 and slot_size > 0 and slot_size % 8 == 0 and expected_size == len(self._mmap):
return
if time.monotonic() >= deadline:
if magic != _MAGIC:
@@ -206,29 +205,32 @@ class ShmRingReader:
f"Shared memory ring magic mismatch for {self._ring_name}: "
f"got=0x{got_magic}, expected=0x{expected_magic}"
)
raise RuntimeError(
f"Shared memory ring version mismatch for {self._ring_name}: "
f"got={version}, expected={_VERSION}"
)
if version != _VERSION:
raise RuntimeError(
f"Shared memory ring version mismatch for {self._ring_name}: "
f"got={version}, expected={_VERSION}"
)
raise RuntimeError(f"Shared memory ring geometry mismatch for {self._ring_name}")
time.sleep(poll_s)
def _wait_for_ring_file(self, timeout_s: float, poll_s: float) -> None:
"""Wait until ring file appears in `/dev/shm`."""
"""Wait for the creator to allocate the ring before mapping its header."""
deadline = time.monotonic() + timeout_s
while not self._path.exists():
while True:
try:
if self._path.stat().st_size >= _HEADER_SIZE:
return
except FileNotFoundError:
pass
if time.monotonic() >= deadline:
raise FileNotFoundError(f"Shared memory ring does not exist: {self._ring_name}")
time.sleep(poll_s)
def _read_u32(self, offset: int) -> int:
"""Read little-endian u32 at mmap offset."""
return struct.unpack_from("<I", self._mmap, offset)[0]
return atomics.load(self._mmap, offset, 4)
def _read_u64(self, offset: int) -> int:
"""Read little-endian u64 at mmap offset."""
return struct.unpack_from("<Q", self._mmap, offset)[0]
def _write_u64(self, offset: int, value: int) -> None:
"""Write little-endian u64 at mmap offset."""
struct.pack_into("<Q", self._mmap, offset, value)
return atomics.load(self._mmap, offset, 8)
+35 -17
View File
@@ -6,9 +6,11 @@ import logging
import mmap
import os
from pathlib import Path
import struct
from typing import Final
from python_app.models.run_config_schema import RingEndpointModel
from python_app.models.run_config_validation import validate_ring_endpoint
from python_app.orchestration.shm import atomics
logger = logging.getLogger(__name__)
_HEADER_SIZE: Final[int] = 64
@@ -32,19 +34,21 @@ class ShmRingWriter:
def __init__(self, ring_name: str, capacity: int, slot_size_bytes: int) -> None:
"""Open or create a POSIX SHM ring by name (as the ring owner)."""
if not ring_name.startswith("/"):
raise ValueError("ring_name must start with '/'")
if capacity <= 0:
raise ValueError("capacity must be > 0")
if slot_size_bytes <= 0:
raise ValueError("slot_size_bytes must be > 0")
if not ring_name.startswith("/") or len(ring_name) < 2 or "/" in ring_name[1:] or "\x00" in ring_name:
raise ValueError("ring_name must be '/' followed by a single nonempty filename")
validate_ring_endpoint(RingEndpointModel(ring_name, capacity, slot_size_bytes))
self._ring_name = ring_name
self._capacity = int(capacity)
self._slot_size_bytes = int(slot_size_bytes)
self._mapped_size = _HEADER_SIZE + self._capacity * (_SLOT_HEADER_SIZE + self._slot_size_bytes)
self._path = Path("/dev/shm") / ring_name[1:]
self._open_owned()
self._file = self._mmap = None
try:
self._open_owned()
except BaseException:
self.close()
raise
def _open_owned(self) -> None:
"""Open the ring, recreating it from scratch on a geometry/header mismatch.
@@ -63,6 +67,10 @@ class ShmRingWriter:
# under a producer/consumer that still expects the old layout.
if not created:
logger.info("Ring %s: stale segment with wrong size, recreating", self._ring_name)
self._file.close()
self._unlink_if_present()
fd = os.open(self._path, os.O_RDWR | os.O_CREAT | os.O_EXCL, 0o660)
self._file = os.fdopen(fd, "r+b", buffering=0)
self._file.truncate(self._mapped_size)
created = True
@@ -84,7 +92,7 @@ class ShmRingWriter:
self._mmap.close()
self._file.close()
self._unlink_if_present()
fd = os.open(self._path, os.O_RDWR | os.O_CREAT, 0o660)
fd = os.open(self._path, os.O_RDWR | os.O_CREAT | os.O_EXCL, 0o660)
self._file = os.fdopen(fd, "r+b", buffering=0)
self._file.truncate(self._mapped_size)
self._mmap = mmap.mmap(self._file.fileno(), self._mapped_size)
@@ -101,8 +109,12 @@ class ShmRingWriter:
def close(self) -> None:
"""Close mmap and file handle."""
self._mmap.close()
self._file.close()
if self._mmap is not None:
self._mmap.close()
self._mmap = None
if self._file is not None:
self._file.close()
self._file = None
logger.debug("Closed SHM ring writer %s", self._ring_name)
def push(self, payload: bytes) -> bool:
@@ -121,13 +133,17 @@ class ShmRingWriter:
# re-read it first and move it only forward: a concurrent reader may have
# already advanced it, and clobbering that backward would re-deliver an
# already-consumed slot as a duplicate.
self._write_u64(32, max(self._read_u64(32), read_seq + 1))
atomics.advance(self._mmap, 32, read_seq + 1)
self._write_u64(40, self._read_u64(40) + 1)
index = write_seq % self._capacity
slot_offset = _HEADER_SIZE + index * (_SLOT_HEADER_SIZE + self._slot_size_bytes)
payload_offset = slot_offset + _SLOT_HEADER_SIZE
# Invalidate the previous publication before changing its bytes. A peek or
# a lapped consumer must not accept the old sequence during this overwrite.
self._write_u64(slot_offset + 8, 0)
atomics.fence()
self._write_u32(slot_offset, len(payload))
self._write_u32(slot_offset + 4, 0)
self._mmap[payload_offset : payload_offset + len(payload)] = payload
@@ -146,7 +162,9 @@ class ShmRingWriter:
return self._slot_size_bytes
def _initialize_header(self) -> None:
self._mmap[:] = b"\x00" * self._mapped_size
# A new ftruncate-extended segment is already zero-filled by the kernel;
# avoid allocating and copying another complete ring just to initialize it.
self._mmap[:_HEADER_SIZE] = b"\x00" * _HEADER_SIZE
self._mmap[:8] = _MAGIC
self._write_u32(8, _VERSION)
self._write_u32(12, self._capacity)
@@ -170,13 +188,13 @@ class ShmRingWriter:
)
def _read_u32(self, offset: int) -> int:
return struct.unpack_from("<I", self._mmap, offset)[0]
return atomics.load(self._mmap, offset, 4)
def _read_u64(self, offset: int) -> int:
return struct.unpack_from("<Q", self._mmap, offset)[0]
return atomics.load(self._mmap, offset, 8)
def _write_u32(self, offset: int, value: int) -> None:
struct.pack_into("<I", self._mmap, offset, int(value))
atomics.store(self._mmap, offset, int(value), 4)
def _write_u64(self, offset: int, value: int) -> None:
struct.pack_into("<Q", self._mmap, offset, int(value))
atomics.store(self._mmap, offset, int(value), 8)