Cut poller/backend CPU on small hosts: lazy entity builds, DR dedup, Whisper cap#118
Merged
Merged
Conversation
…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 <[email protected]> Claude-Session: https://claude.ai/code/session_017n1uFNfMESakg9E9qoMDrh
d3mocide
marked this pull request as ready for review
July 6, 2026 15:59
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes the CPU regression observed after PR #117 — both cores pegged, load ~5.0 on a 2-core VPS. The hot paths were benchmarked with synthetic BEAST traffic; each change below addresses a measured cost, not a guess.
The regression
PR #117's dead reckoning made stale aircraft's
lat/lonadvance with wall-clock time. Those keys are in_entity_changed's compare set, so aircraft that previously went quiet when stale started re-publishing every second: Redis pub/sub → backend WS fan-out (per client, per worker) → DB write → frontend render. On top of that, several pre-existing per-frame and per-publish costs were paying for work that was thrown away.Changes
Poller — ADS-B pipeline
beast_decoder.py,adsb.py): the frame worker built a full entity dict (trail copy, comm-B snapshot, DR projection, ISO timestamp) on every decoded frame, but only published at ≤1/s per aircraft.ingest()is split intoingest_frame()(state-only) +entity_from_state(); the dict is now built only when the publish gate passes. ~22% cheaper per frame;ingest()remains as a compat wrapper so existing tests/callers are untouched. Best Mode arbitration semantics preserved ("beast seen" still recorded only for positioned aircraft).adsb.py):snapshot_entities()— an O(aircraft) full entity rebuild — ran every tick (1s) while its only consumer, the enriched snapshot publish, runs every 5s. It now runs once per snapshot tick, immediately before the publish (80% less of that work, and the data is strictly fresher). DR still advances through a total feed outage.bus.py): while both previous and current state are dead-reckoned,_entity_changedskipslat/lon/altitude— they're synthetic projections, and the frontend already projects DR tracks client-side (pvb.ts). Real telemetry changes (heading, speed, vertical rate, squawk, …) and DR on/off transitions (position_dradded to the compare keys) still publish immediately.bus.py,db.py):write_entity_observationre-ransanitize_payload— a recursive deep-copy that walks all 150 trail points, ~216µs/call — on entities its only caller had already sanitized. Asanitized=Trueflag skips the redundant pass, roughly halving per-publish CPU.beast_transport.py): frames whose wire body contains no0x1Aescape byte (the overwhelmingly common case) are sliced out at C speed viabytearray.findinstead of the per-byte Python loop. Escaped, incomplete, and garbage input still goes through the unchangedparse_frameslow path.Transcription
WHISPER_CPU_THREADSsetting (default 1) passed toWhisperModel, and the container's compose CPU limit reduced2.0 → 1.0. Previously ctranslate2 grabbed every core whenever a P25 call transcribed, starving the poller/backend/map on a 2-core host. Base/int8 on one thread still transcribes short P25 clips faster than realtime; raise the env var on bigger hosts.Measurements (synthetic 50/50 short/long frame mix)
Tests
poller/tests/test_beast_transport.py(11 tests): fast path, escaped MLAT/signal/message bytes, garbage resync, incomplete buffers, 0x31 skipping — the parser previously had no tests.test_bus.py.tsc --noEmitclean,docker compose configclean.🤖 Generated with Claude Code
https://claude.ai/code/session_017n1uFNfMESakg9E9qoMDrh
Generated by Claude Code