Skip to content

Commit 90b704d

Browse files
committed
Cut poller/backend CPU on small hosts: lazy entity builds, DR dedup, Whisper cap
Fixes the CPU regression observed after PR #117 (both cores pegged, load ~5.0 on a 2-core VPS). Benchmarked the hot paths with synthetic BEAST traffic and addressed the measured costs: Poller: - Frame worker no longer builds the full entity dict (trail copy, comm-B snapshot, DR projection, ISO timestamp) on every decoded frame — only for the <=1/s-per-aircraft publishes. New decoder methods ingest_frame()/entity_from_state(); ingest() kept as a compat wrapper. ~22% cheaper per frame. - Registry tick loop rebuilds decoder entities once per 5s snapshot tick (right before its only consumer) instead of every second, cutting that O(aircraft) rebuild by 80%. - Dead-reckoned aircraft no longer republish every second: their lat/lon/altitude are wall-clock projections that always tripped the _entity_changed dedup, flooding Redis pub/sub, the backend WS fan-out, DB writes, and frontend renders — the main per-message regression from PR #117. The frontend already projects DR tracks client-side (pvb.ts), so projection-only changes are now skipped; real telemetry and DR on/off transitions still publish immediately. - Dropped the redundant second sanitize_payload deep-copy per publish (write_entity_observation re-sanitized what publish_entity already had, walking all 150 trail points each time). - BEAST transport parses escape-free frames (the common case) via bytearray.find slicing instead of the per-byte Python loop: 3.7x faster (4.0us -> 1.1us per frame). Transcription: - New WHISPER_CPU_THREADS setting (default 1) passed to WhisperModel, and compose CPU limit 2.0 -> 1.0, so ctranslate2 can no longer saturate every core whenever a P25 call transcribes. Tests: new test_beast_transport.py (11 tests) covering the fast path, escaped frames, garbage resync, and incomplete buffers; 5 new DR-dedup tests in test_bus.py. Full poller suite: 171 passed. tsc clean, docker compose config clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017n1uFNfMESakg9E9qoMDrh
1 parent 5c939a7 commit 90b704d

12 files changed

Lines changed: 328 additions & 51 deletions

File tree

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,10 @@ WHISPER_MODEL=base
214214
WHISPER_LANGUAGE=en
215215
WHISPER_COMPUTE_TYPE=int8
216216
WHISPER_DEVICE=cpu
217+
# CPU threads for Whisper inference. Keep at 1 on small hosts (2-core VPS /
218+
# Pi) so transcription bursts can't starve the poller and API; raise if you
219+
# have spare cores.
220+
WHISPER_CPU_THREADS=1
217221

218222
# FlashAlert & TVFR feed fallbacks (RSS/REST URL overrides)
219223
FLASHALERT_ENABLED=false

TASK_LOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,19 @@ Format: `## YYYY-MM-DD — <summary>` with bullet points for details.
55

66
---
77

8+
## 2026-07-06 — CPU usage optimization for small hosts (2-core VPS)
9+
10+
- **Motivation**: User reported both cores pegged (load ~5.0 on 2 cores) after the ADS-B pipeline overhaul (PR #117). Benchmarked the hot paths with synthetic BEAST traffic and fixed the measured costs.
11+
- **Lazy entity building in the BEAST frame worker** ([beast_decoder.py](poller/normalizers/beast_decoder.py), [adsb.py](poller/pollers/adsb.py)):
12+
- Split `ingest()` into `ingest_frame()` (state-only decode, runs per frame) and `entity_from_state()` (full entity dict build). The frame worker now builds the entity dict — trail copy, comm-B snapshot, DR projection, ISO timestamp — only when the ≤1/s-per-aircraft publish gate passes instead of on every decoded frame (~22% cheaper per frame; `ingest()` kept as a compat wrapper).
13+
- Best Mode arbitration semantics preserved: "beast seen" is still only recorded for positioned aircraft.
14+
- **Registry tick loop rebuild moved to snapshot cadence** ([adsb.py](poller/pollers/adsb.py)): `snapshot_entities()` (an O(aircraft) full entity rebuild) ran every second while its only consumer — the enriched snapshot publish — runs every 5s. It now runs once per snapshot tick, immediately before the publish, cutting that work by 80%. DR still advances through a total feed outage since the rebuild happens on the same tick that publishes.
15+
- **Dead-reckoning republish suppression** ([bus.py](poller/bus.py)): while an aircraft is dead-reckoned, its lat/lon/altitude are synthetic wall-clock projections that made `_entity_changed` report a change every second — so previously-quiet stale aircraft flooded Redis pub/sub, the backend WS fan-out, DB writes, and frontend renders (the main per-message CPU regression from PR #117). Since the frontend already projects DR tracks client-side (pvb.ts), `_entity_changed` now skips lat/lon/altitude when both states are DR. Real telemetry changes and DR on/off transitions (`position_dr` added to compare keys) still publish immediately.
16+
- **Halved per-publish sanitize cost** ([bus.py](poller/bus.py), [db.py](poller/db.py)): `write_entity_observation` re-ran `sanitize_payload` (a recursive deep-copy that walks all 150 trail points, ~216µs per call) on entities its only caller `publish_entity` had already sanitized. New `sanitized=True` flag skips the redundant pass.
17+
- **BEAST transport fast path** ([beast_transport.py](poller/pollers/beast_transport.py)): frames whose wire body contains no 0x1A escape byte (the overwhelmingly common case) are now sliced out at C speed with `bytearray.find` instead of the per-byte Python loop — 3.7× faster frame parsing (4.0µs → 1.1µs/frame). Escaped/incomplete/garbage frames still go through `parse_frame`.
18+
- **Whisper transcription CPU cap** ([docker-compose.yml](docker-compose.yml), [transcription/config.py](transcription/config.py), [transcription/main.py](transcription/main.py), [.env.example](.env.example)): ctranslate2 grabbed every core whenever a P25 call transcribed, starving the poller/backend on a 2-core host. New `WHISPER_CPU_THREADS` setting (default 1) passed to `WhisperModel`, and the container's compose CPU limit reduced from 2.0 to 1.0. Base/int8 on one thread still transcribes short P25 clips faster than realtime.
19+
- **Tests**: new [test_beast_transport.py](poller/tests/test_beast_transport.py) (11 tests: fast path, escaped MLAT/signal/message bytes, garbage resync, incomplete buffers, 0x31 skipping) and 5 DR-dedup tests in [test_bus.py](poller/tests/test_bus.py). Full poller suite: 171 passed.
20+
821
## 2026-07-06 — ADS-B pipeline overhaul: dead reckoning, signal-gap handling, stale-track rendering
922

1023
- **Server-side dead reckoning** ([beast_decoder.py](poller/normalizers/beast_decoder.py), [config.py](poller/config.py)):

docker-compose.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,9 @@ services:
192192
resources:
193193
limits:
194194
memory: 2g
195-
cpus: '2.0'
195+
# Transcription is a background task — never let it saturate the
196+
# host (map/API responsiveness comes first on 2-core deployments).
197+
cpus: '1.0'
196198
reservations:
197199
memory: 512m
198200
cpus: '0.5'

poller/bus.py

Lines changed: 37 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,10 @@ async def publish_entity(
9292
return
9393

9494
try:
95-
await write_entity_observation(entity, record_observation=record_observation)
95+
# sanitized=True: this entity already went through sanitize_payload()
96+
# above — skipping the second recursive deep-copy (which walks every
97+
# trail point) roughly halves the per-publish CPU cost.
98+
await write_entity_observation(entity, record_observation=record_observation, sanitized=True)
9699
except Exception as exc:
97100
import traceback
98101
logger.warning("DB write failed for %s: %s\n%s", entity.get("entity_id"), exc, traceback.format_exc())
@@ -126,25 +129,40 @@ async def close():
126129
_redis = None
127130

128131

132+
_COMPARE_KEYS = (
133+
"entity_type",
134+
"source",
135+
"display_name",
136+
"lat",
137+
"lon",
138+
"altitude",
139+
"heading",
140+
"speed",
141+
"vertical_rate",
142+
"status",
143+
"identity",
144+
"tags",
145+
"position_stale",
146+
"position_dr",
147+
"trail_pts",
148+
"comm_b",
149+
)
150+
151+
# While an aircraft is being dead-reckoned, its lat/lon/altitude are synthetic
152+
# projections that advance with wall-clock time — every rebuild "changes" them
153+
# even though no new data arrived. The frontend projects DR tracks client-side
154+
# (pvb.ts), so republishing each projection is pure overhead: skip those keys
155+
# when both the previous and current state are dead-reckoned. Real telemetry
156+
# (heading, speed, vertical_rate, squawk, ...) and the DR on/off transitions
157+
# themselves still publish immediately.
158+
_DR_SKIP_KEYS = frozenset(("lat", "lon", "altitude"))
159+
160+
129161
def _entity_changed(previous: dict, current: dict) -> bool:
130-
compare_keys = (
131-
"entity_type",
132-
"source",
133-
"display_name",
134-
"lat",
135-
"lon",
136-
"altitude",
137-
"heading",
138-
"speed",
139-
"vertical_rate",
140-
"status",
141-
"identity",
142-
"tags",
143-
"position_stale",
144-
"trail_pts",
145-
"comm_b",
146-
)
147-
for key in compare_keys:
162+
both_dr = bool(previous.get("position_dr")) and bool(current.get("position_dr"))
163+
for key in _COMPARE_KEYS:
164+
if both_dr and key in _DR_SKIP_KEYS:
165+
continue
148166
if previous.get(key) != current.get(key):
149167
return True
150168
return False

poller/db.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,11 +122,17 @@ async def close_db():
122122
_pool = None
123123

124124

125-
async def write_entity_observation(entity: dict, record_observation: bool = True):
126-
"""Upsert entity row and append an observation. Runs geofence check if positioned."""
125+
async def write_entity_observation(entity: dict, record_observation: bool = True, sanitized: bool = False):
126+
"""Upsert entity row and append an observation. Runs geofence check if positioned.
127+
128+
Pass sanitized=True when the payload already went through sanitize_payload()
129+
(publish_entity does) to skip a redundant recursive deep-copy of the entity —
130+
trail-carrying aircraft entities make that copy expensive.
131+
"""
127132
if _pool is None:
128133
return
129-
entity = sanitize_payload(entity)
134+
if not sanitized:
135+
entity = sanitize_payload(entity)
130136

131137
from geofence import check_geofences # lazy — breaks bus→db→geofence→bus cycle
132138

poller/normalizers/beast_decoder.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,28 @@ def __init__(self):
9595
self._last_prune_ts: float = 0.0
9696

9797
def ingest(self, message_bytes: bytes, *, mlat_ticks: int | None = None, signal: int | None = None) -> dict | None:
98+
"""Decode one frame and return the full entity dict (or None).
99+
100+
Convenience wrapper around ingest_frame() + entity_from_state(). The
101+
hot path (adsb frame worker) uses those two directly so the entity
102+
dict is only built for the ~1/s-per-aircraft publishes instead of on
103+
every decoded frame.
104+
"""
105+
ac = self.ingest_frame(message_bytes, mlat_ticks=mlat_ticks, signal=signal)
106+
if ac is None:
107+
return None
108+
return self._to_entity(ac)
109+
110+
def entity_from_state(self, ac: _AircraftState, now: float | None = None) -> dict | None:
111+
"""Build the publishable entity dict for a decoded aircraft state."""
112+
return self._to_entity(ac, now=now)
113+
114+
def ingest_frame(self, message_bytes: bytes, *, mlat_ticks: int | None = None, signal: int | None = None) -> _AircraftState | None:
115+
"""Decode one Mode S frame into aircraft state, without building an entity.
116+
117+
Returns the updated _AircraftState (which may not have a position yet),
118+
or None if the frame was rejected/undecodable.
119+
"""
98120
if pms is None:
99121
if not self._warned_missing_dep:
100122
logger.warning("[adsb] pyModeS not available; BEAST decode disabled")
@@ -189,7 +211,7 @@ def ingest(self, message_bytes: bytes, *, mlat_ticks: int | None = None, signal:
189211
self._prune_stale()
190212
self._last_prune_ts = now
191213

192-
return self._to_entity(ac)
214+
return ac
193215

194216
def seed_reference(self, icao: str, lat: float, lon: float, ts: float | None = None) -> bool:
195217
"""Seed a position reference from another source (OpenSky / ultrafeeder).

poller/pollers/adsb.py

Lines changed: 30 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -224,20 +224,29 @@ async def _process_beast_frames(self):
224224
while True:
225225
msg, mlat_ticks, signal = await self._beast_queue.get()
226226
try:
227-
entity = self._beast_decoder.ingest(msg, mlat_ticks=mlat_ticks, signal=signal)
228-
if entity:
229-
icao = (entity.get("identity") or {}).get("icao24", "").lower()
227+
# State-only decode: the full entity dict (trail copy, comm-B
228+
# snapshot, DR projection, ISO timestamp) is built lazily below,
229+
# only for the ≤1/s-per-aircraft publishes — not per frame.
230+
state = self._beast_decoder.ingest_frame(msg, mlat_ticks=mlat_ticks, signal=signal)
231+
# Positioned aircraft only, matching the previous behavior where
232+
# a position-less decode produced no entity: recording "beast"
233+
# for unpositioned aircraft would wrongly suppress the
234+
# ultrafeeder/OpenSky sources in Best Mode arbitration.
235+
if state is not None and state.lat is not None and state.lon is not None:
236+
icao = state.icao
230237
self._record_source_seen(icao, "beast")
231-
self._unified_entities[icao] = entity
232238
now = time.time()
233239
if now - _last_published.get(icao, 0.0) >= _BEAST_PUBLISH_MIN_INTERVAL:
234-
_last_published[icao] = now
235-
# Dead-reckoned positions are estimates — keep them out
236-
# of the observation history (trails stay real fixes only).
237-
await publish_entity(
238-
entity,
239-
record_observation=not entity.get("position_dr"),
240-
)
240+
entity = self._beast_decoder.entity_from_state(state, now=now)
241+
if entity:
242+
_last_published[icao] = now
243+
self._unified_entities[icao] = entity
244+
# Dead-reckoned positions are estimates — keep them out
245+
# of the observation history (trails stay real fixes only).
246+
await publish_entity(
247+
entity,
248+
record_observation=not entity.get("position_dr"),
249+
)
241250
except Exception as exc:
242251
logger.warning("[adsb] frame processing error: %s", exc)
243252

@@ -249,7 +258,6 @@ async def _process_beast_frames(self):
249258

250259
async def _registry_tick_loop(self):
251260
_SNAPSHOT_INTERVAL = 5 # publish full snapshot every N ticks (seconds)
252-
_last_frames_seen = self._transport.frames_seen
253261

254262
while True:
255263
await asyncio.sleep(1.0)
@@ -269,19 +277,6 @@ async def _registry_tick_loop(self):
269277
del self._last_seen_by_source[icao]
270278

271279
try:
272-
# Pull fresh BEAST positions into the unified registry when new
273-
# frames have arrived since the last tick — skips the O(aircraft)
274-
# entity reconstruction when the decoder state is unchanged.
275-
# Also refresh on every snapshot tick regardless: dead-reckoned
276-
# display positions advance with wall-clock time, so a total
277-
# feed gap must not freeze the published snapshot.
278-
current_frames = self._transport.frames_seen
279-
if current_frames != _last_frames_seen or self._tick_count % _SNAPSHOT_INTERVAL == 0:
280-
_last_frames_seen = current_frames
281-
for ac in self._beast_decoder.snapshot_entities():
282-
icao = (ac.get("identity") or {}).get("icao24", "").lower()
283-
self._unified_entities[icao] = ac
284-
285280
# Evict entries silent for more than 2 minutes (runs every tick, cheap)
286281
stale_cutoff = 120.0
287282
to_remove = [
@@ -294,6 +289,16 @@ async def _registry_tick_loop(self):
294289
# Publish full enriched snapshot at reduced cadence — individual
295290
# entity updates still arrive in real time via publish_entity().
296291
if self._tick_count % _SNAPSHOT_INTERVAL == 0:
292+
# Rebuild decoder entities only here, immediately before the
293+
# snapshot that consumes them — nothing reads the registry
294+
# between snapshots (real-time flow goes via publish_entity),
295+
# so the previous every-tick O(aircraft) rebuild was 80%
296+
# wasted work. Rebuilding on the snapshot tick also keeps
297+
# dead reckoning advancing through a total feed outage.
298+
for ac in self._beast_decoder.snapshot_entities():
299+
ac_icao = (ac.get("identity") or {}).get("icao24", "").lower()
300+
self._unified_entities[ac_icao] = ac
301+
297302
snapshot_ents = [
298303
entity for icao, entity in self._unified_entities.items()
299304
if self._should_publish_from_source(icao, entity.get("source", "unknown"))

poller/pollers/beast_transport.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@
2121

2222
logger = logging.getLogger(__name__)
2323

24+
# BEAST frame type -> Mode S payload length in bytes.
25+
# 0x31 = 2-byte short squitter (skipped), 0x32 = 7-byte short, 0x33 = 14-byte long.
26+
_PAYLOAD_LEN = {0x31: 2, 0x32: 7, 0x33: 14}
27+
2428

2529
class BeastTransport:
2630
"""Manages a BEAST TCP connection and delivers raw Mode S frames.
@@ -117,9 +121,30 @@ def consume_buffer(
117121
buffer after this call.
118122
"""
119123
pos = 0
124+
blen = len(buffer)
120125
messages: list[tuple[bytes, int, int]] = []
121126

122-
while pos < len(buffer):
127+
while pos < blen:
128+
# Fast path: a frame whose wire body contains no 0x1A byte needs no
129+
# unescaping, so it can be sliced out directly at C speed. This is
130+
# the overwhelmingly common case (0x1A appears in ~0.4% of body
131+
# bytes), and it avoids the per-byte Python loop in parse_frame().
132+
if buffer[pos] == 0x1A and pos + 1 < blen:
133+
payload_len = _PAYLOAD_LEN.get(buffer[pos + 1])
134+
if payload_len is not None:
135+
body_start = pos + 2
136+
body_end = body_start + 7 + payload_len
137+
if body_end <= blen and buffer.find(0x1A, body_start, body_end) == -1:
138+
if buffer[pos + 1] != 0x31: # 0x31 short squitter: skip
139+
messages.append((
140+
bytes(buffer[body_start + 7:body_end]),
141+
int.from_bytes(buffer[body_start:body_start + 6], "big"),
142+
buffer[body_start + 6],
143+
))
144+
pos = body_end
145+
continue
146+
147+
# Slow path: escapes present, buffer incomplete, or garbage bytes.
123148
consumed, message = self.parse_frame(memoryview(buffer)[pos:])
124149
if consumed == 0:
125150
break # incomplete frame; wait for more data
@@ -161,7 +186,7 @@ def parse_frame(
161186
return -(next_sync + 1), None
162187

163188
frame_type = view[1]
164-
payload_len = {0x31: 2, 0x32: 7, 0x33: 14}.get(frame_type)
189+
payload_len = _PAYLOAD_LEN.get(frame_type)
165190
if payload_len is None:
166191
return -1, None
167192

0 commit comments

Comments
 (0)