# Signal State Memory Analysis ## Struct Sizes (verified at runtime in `trimmer.rs` tests) | Component | Size | Notes | |-----------|------|-------| | `HotSignalState` | **64 bytes** | `#[repr(C, align(64))]` — exactly one cache line. Compile-time asserted. | | `BucketedCounter` | **~944 bytes** | `[AtomicU32; 60]` (240B) + `[AtomicU32; 168]` (672B) + 2×`AtomicU8` (2B) + 3×`AtomicU64` (24B) + alignment padding ≈ 944B | | `EntitySignalEntry` (hot + warm) | **~1,008 bytes** | `HotSignalState` + `BucketedCounter` | | DashMap per-entry overhead | **~80 bytes** | Hash metadata, shard lock overhead, key storage | | **Total per `(entity, signal_type)` entry** | **~1,088 bytes** | | ## Memory Projection | Scale | Entries | Projected Memory | |-------|---------|-----------------| | 100K items × 2 signals | 200K | ~218 MB | | 100K items × 10 signals | 1M | ~1.1 GB | | 1M items × 2 signals | 2M | ~2.2 GB | | **1M items × 10 signals** | **10M** | **~10.4 GB** | | 5M items × 10 signals | 50M | ~52 GB | ## Budget Assessment **Budget:** 10 GB signal state target. **Result at 1M × 10 signals:** ~10.4 GB — **marginally over budget (4% headroom)** This is close enough that we need trimming. The `trim_cold_entries` function in `tidal/src/signals/trimmer.rs` provides a time-ordered eviction policy with a default threshold of **5M entries (~5.4 GB)** — providing a 2× safety margin below the 10 GB budget. ## Memory Bounding via LRU Trimmer ### Threshold ```rust pub const DEFAULT_MAX_SIGNAL_ENTRIES: usize = 5_000_000; // ~5.44 GB ``` ### Policy - **Oldest first:** entries sorted by `last_update_ns` ascending - **Batch eviction:** removes enough entries to reach `max_entries` in a single pass - **O(N log N)** complexity per eviction, amortised across checkpoint intervals (every 30s) - **Thread-safe:** uses DashMap's snapshot iteration + per-shard removes ### Trigger Trimming runs in the checkpoint background thread (`run_checkpoint_thread` in `db/state_rebuild.rs`), checked every 30 seconds: ```rust if ledger.entries().len() > DEFAULT_MAX_SIGNAL_ENTRIES { trim_cold_entries(ledger.entries(), DEFAULT_MAX_SIGNAL_ENTRIES); } ``` ### Correctness Re-signalling an evicted entity creates a fresh entry. The entry will miss some historical window counts (BucketedCounter is zeroed on creation), but decay scores are accurate from the new events forward. This is acceptable for a ranking system: stale signal state is less accurate than no signal state. ## Actual DashMap Overhead Measurement To verify the ~80 bytes/entry DashMap overhead assumption, measure empirically: ```rust let map: DashMap<(u64, u16), [u8; 1008]> = DashMap::new(); // Insert N entries // Read /proc/self/status VmRSS before and after // overhead_per_entry = (after - before - N * 1008) / N ``` Expected result: ~60-100 bytes/entry depending on load factor and shard count. The 80-byte assumption is conservative (favors detecting memory problems early). ## Signal Coverage in Practice Not all `(entity, signal_type)` pairs are populated: - Only signalled entities have entries - At 10% view coverage (100K/1M items): 100K entries × 10 signals = 1M entries = ~1.1 GB - The trimmer only fires when load exceeds 5M entries — typical production workloads will stay well below this unless operating at very high write throughput for extended periods.