Skip to content

Cut poller/backend CPU on small hosts: lazy entity builds, DR dedup, Whisper cap#118

Merged
d3mocide merged 1 commit into
mainfrom
claude/cpu-usage-optimization-hhfo8n
Jul 6, 2026
Merged

Cut poller/backend CPU on small hosts: lazy entity builds, DR dedup, Whisper cap#118
d3mocide merged 1 commit into
mainfrom
claude/cpu-usage-optimization-hhfo8n

Conversation

@d3mocide

@d3mocide d3mocide commented Jul 6, 2026

Copy link
Copy Markdown
Owner

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/lon advance 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

  • Lazy entity building (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 into ingest_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).
  • Snapshot rebuild moved to snapshot cadence (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.
  • DR republish suppression (bus.py): while both previous and current state are dead-reckoned, _entity_changed skips lat/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_dr added to the compare keys) still publish immediately.
  • Single sanitize per publish (bus.py, db.py): write_entity_observation re-ran sanitize_payload — a recursive deep-copy that walks all 150 trail points, ~216µs/call — on entities its only caller had already sanitized. A sanitized=True flag skips the redundant pass, roughly halving per-publish CPU.
  • BEAST transport fast path (beast_transport.py): frames whose wire body contains no 0x1A escape byte (the overwhelmingly common case) are sliced out at C speed via bytearray.find instead of the per-byte Python loop. Escaped, incomplete, and garbage input still goes through the unchanged parse_frame slow path.

Transcription

  • New WHISPER_CPU_THREADS setting (default 1) passed to WhisperModel, and the container's compose CPU limit reduced 2.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)

Path Before After
Transport frame parse 3.99 µs/frame 1.09 µs/frame (3.7×)
Decoder per-frame (worker path) 20.5 µs 16.8 µs (~22% incl. skipped entity builds)
Per-publish sanitize 2 × 216 µs 1 × 216 µs
DR'd stale aircraft publish rate 1/s each only on real telemetry change

Tests

  • New 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.
  • 5 new DR-dedup tests in test_bus.py.
  • Full poller suite: 171 passed. tsc --noEmit clean, docker compose config clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_017n1uFNfMESakg9E9qoMDrh


Generated by Claude Code

…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
d3mocide marked this pull request as ready for review July 6, 2026 15:59
@d3mocide
d3mocide merged commit 666a7b9 into main Jul 6, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants