Skip to main content

subetha_core/
observation.rs

1//! TLS-local observation ring.
2//!
3//! Primitives push small observation records on every flagged op; the
4//! sidecar consumes from the ring asynchronously. Push cost is one
5//! relaxed store + branch + increment - ~3 cycles steady state.
6//!
7//! The ring is single-producer (the owning thread) and single-consumer
8//! (the sidecar). The producer never blocks; if the ring is full, the
9//! push is dropped silently (sampling, not coordination).
10//!
11//! Each observation carries a `producer_thread_id` (a process-local
12//! sequential u32 allocated lazily per-thread via [`thread_id`]). The
13//! sidecar's drain folds these into per-op-kind cardinality tracking on
14//! `InstanceStats`, letting policies detect multi-producer / multi-
15//! consumer patterns directly instead of inferring them from FLAG_FULL
16//! / FLAG_EMPTY proxies.
17
18use core::sync::atomic::{AtomicBool, AtomicPtr, AtomicU32, Ordering};
19
20const RING_CAPACITY: usize = 4096;
21
22/// One observation record. 24 bytes - fits between two consecutive
23/// cache-line boundaries (3 per line, no straddling). The width
24/// carries a per-thread sequential identifier alongside the op data.
25#[derive(Clone, Copy, Debug)]
26#[repr(C)]
27pub struct Observation {
28    /// Stable identifier for the originating primitive instance.
29    pub instance_id: u32,
30    /// Op kind (primitive-specific).
31    pub op_kind: u16,
32    /// Bit flags (contention, miss, cold path, etc.).
33    pub flags: u16,
34    /// Latency in raw TSC ticks.
35    pub latency_ticks: u64,
36    /// Process-local sequential thread id of the producer
37    /// (returned by [`thread_id`]). 0 = unspecified.
38    pub producer_thread_id: u32,
39    /// Reserved for future-proofing the struct size to a multiple of
40    /// 8 bytes; keeps Observation at 24 bytes (3 per cache line).
41    pub _reserved: u32,
42}
43
44impl Observation {
45    pub const ZERO: Self = Self {
46        instance_id: 0,
47        op_kind: 0,
48        flags: 0,
49        latency_ticks: 0,
50        producer_thread_id: 0,
51        _reserved: 0,
52    };
53}
54
55/// Process-local sequential thread id.
56///
57/// Returns the same value on every call from the current thread. The
58/// id is allocated lazily on first call per thread via an atomic
59/// counter - no syscalls. The first thread that calls this gets id 1
60/// (id 0 is reserved for "unspecified" so the Observation default is
61/// distinguishable from a real thread).
62///
63/// Stable across all observation pushes from the same thread; valid
64/// for the lifetime of the process; not valid across forks (the child
65/// keeps the parent's counter but reissues new ids to its own threads).
66#[inline]
67pub fn thread_id() -> u32 {
68    thread_local! {
69        static TID: core::cell::Cell<u32> = const { core::cell::Cell::new(0) };
70    }
71    TID.with(|cell| {
72        let cached = cell.get();
73        if cached != 0 {
74            return cached;
75        }
76        static NEXT: AtomicU32 = AtomicU32::new(1);
77        let id = NEXT.fetch_add(1, Ordering::Relaxed);
78        // Wrap-around: if the process spawned more than ~4 billion
79        // threads we'd hit 0 again. Skip 0 to keep it as the sentinel.
80        let id = if id == 0 { NEXT.fetch_add(1, Ordering::Relaxed) } else { id };
81        cell.set(id);
82        id
83    })
84}
85
86/// Process-global count of currently-armed observation rings.
87///
88/// The hot-path guard [`any_observer_armed`] reads this. When it is 0 - no
89/// consumer has attached a sidecar anywhere in the process, the raw-handle
90/// production case for every primitive - a per-op observation guard is a
91/// single relaxed load on this always-L1-resident global plus a
92/// predicted-not-taken branch, with the actual push kept out-of-line behind
93/// `#[cold]` so the op's hot path stays small enough to inline into its
94/// caller. Incremented on the disarmed->armed edge in [`ObservationRing::arm`];
95/// decremented when an armed ring drops.
96pub static ARMED_COUNT: AtomicU32 = AtomicU32::new(0);
97
98/// True if any observation ring in the process is currently armed - one
99/// relaxed load on the always-hot [`ARMED_COUNT`] global, touching no
100/// primitive state (no `self`, no boxed-ring deref). The intended shape is
101/// `if any_observer_armed() { self.push_<op>_cold() }` where the cold method
102/// is `#[cold] #[inline(never)]`: the raw-handle hot path never reads the
103/// cold boxed-ring line and never grows past its caller's inline threshold.
104#[inline(always)]
105pub fn any_observer_armed() -> bool {
106    ARMED_COUNT.load(Ordering::Relaxed) != 0
107}
108
109/// SPSC ring used by one producer thread (push) and one consumer (sidecar).
110///
111/// Head is written by the consumer, read by the producer.
112/// Tail is written by the producer, read by the consumer.
113#[repr(C, align(64))]
114pub struct ObservationRing {
115    head: AtomicU32,
116    _pad0: [u8; 60],
117    tail: AtomicU32,
118    /// Producer-side gate. A ring starts disarmed; producers skip every
119    /// push (no `thread_id` TLS, no struct store) until a consumer arms
120    /// it via [`ObservationRing::arm`] at sidecar registration. On a raw
121    /// `create()` handle no sidecar ever attaches, so the push is elided
122    /// entirely. Co-located with `tail` so the per-push check reads the
123    /// cache line the producer already owns.
124    armed: AtomicBool,
125    _pad_a: [u8; 3],
126    /// Lazily-allocated heap buffer of `RING_CAPACITY` observations, null
127    /// until armed. A raw `create()` handle that never attaches a sidecar
128    /// never allocates the ~96 KiB buffer - the dominant per-instance
129    /// cost of the observation machinery. `arm()` allocates it
130    /// (zero-filled, i.e. all `Observation::ZERO`) and publishes the
131    /// pointer before setting `armed`. Co-located with `tail`/`armed` so
132    /// the producer reads gate + buffer pointer from one cache line.
133    buf: AtomicPtr<core::cell::UnsafeCell<Observation>>,
134    _pad1: [u8; 48],
135}
136
137unsafe impl Sync for ObservationRing {}
138
139impl ObservationRing {
140    pub const fn new() -> Self {
141        Self {
142            head: AtomicU32::new(0),
143            _pad0: [0; 60],
144            tail: AtomicU32::new(0),
145            armed: AtomicBool::new(false),
146            _pad_a: [0; 3],
147            buf: AtomicPtr::new(core::ptr::null_mut()),
148            _pad1: [0; 48],
149        }
150    }
151
152    /// Heap layout of the lazily-allocated observation buffer.
153    #[inline]
154    fn buf_layout() -> std::alloc::Layout {
155        std::alloc::Layout::array::<core::cell::UnsafeCell<Observation>>(RING_CAPACITY)
156            .expect("observation buffer layout is valid")
157    }
158
159    /// Push one observation. Returns `true` on success, `false` if the
160    /// ring was full (observation dropped).
161    ///
162    /// If `obs.producer_thread_id` is 0 (the default), the current
163    /// thread's id is stamped in automatically via [`thread_id`]. Call
164    /// sites that prefer explicit attribution can pre-fill the field;
165    /// the auto-stamp keeps the per-primitive push sites mechanical.
166    #[inline(always)]
167    pub fn push(&self, obs: Observation) -> bool {
168        // Hot gate: a single relaxed load on the always-L1 process-global,
169        // predicted-not-taken on the raw-handle path. Crucially the actual
170        // store machinery is out-of-line behind `#[cold]`, so this method's
171        // hot path is just load + test + branch - small enough that callers
172        // inline it instead of emitting a call per op. The caller-built `obs`
173        // is unused on this path and sinks into the cold body. When no
174        // consumer is armed anywhere in the process, ARMED_COUNT is 0 and
175        // the per-op observation cost is effectively nil (measured: a
176        // bit_vec get stays at its ~1.8 ns no-observation floor).
177        if ARMED_COUNT.load(Ordering::Relaxed) == 0 {
178            return false;
179        }
180        self.push_cold(obs)
181    }
182
183    /// Push an observation described by just its op kind and flags - the
184    /// shape essentially every primitive uses. Passing scalars (not a built
185    /// `Observation`) is what keeps the win whole: the struct is constructed
186    /// only inside the `#[cold]` body, so the caller's hot path is a lone
187    /// relaxed load + predicted branch and nothing materializes on it. This
188    /// is the preferred per-op observation entry point; reserve
189    /// [`push`](Self::push) for the rare site that pre-fills
190    /// `latency_ticks` / `instance_id`.
191    #[inline(always)]
192    pub fn push_op(&self, op_kind: u16, flags: u16) -> bool {
193        if ARMED_COUNT.load(Ordering::Relaxed) == 0 {
194            return false;
195        }
196        self.push_cold(Observation { op_kind, flags, ..Observation::ZERO })
197    }
198
199    /// Out-of-line store path, reached only when some ring in the process is
200    /// armed. `#[cold]` + `#[inline(never)]` keep the per-op observation
201    /// machinery off every primitive's hot path; the still-cheap
202    /// `self.armed` recheck confirms it is THIS ring that a consumer attached.
203    #[cold]
204    #[inline(never)]
205    fn push_cold(&self, mut obs: Observation) -> bool {
206        if !self.armed.load(Ordering::Relaxed) {
207            return false;
208        }
209        // Armed implies the buffer was allocated and published before
210        // `armed` was set; the Acquire load pairs with the Release store
211        // in `arm`. The null guard covers the brief window where a relaxed
212        // observer sees `armed` ahead of the buffer pointer.
213        let buf = self.buf.load(Ordering::Acquire);
214        if buf.is_null() {
215            return false;
216        }
217        if obs.producer_thread_id == 0 {
218            obs.producer_thread_id = thread_id();
219        }
220        let tail = self.tail.load(Ordering::Relaxed);
221        let head = self.head.load(Ordering::Acquire);
222        let next = tail.wrapping_add(1);
223        if next.wrapping_sub(head) as usize > RING_CAPACITY {
224            return false;
225        }
226        let slot = (tail as usize) % RING_CAPACITY;
227        unsafe { *(*buf.add(slot)).get() = obs; }
228        self.tail.store(next, Ordering::Release);
229        true
230    }
231
232    /// Consumer-side pop. Single-consumer; caller must serialize.
233    pub fn pop(&self) -> Option<Observation> {
234        let buf = self.buf.load(Ordering::Acquire);
235        if buf.is_null() {
236            return None;
237        }
238        let head = self.head.load(Ordering::Relaxed);
239        let tail = self.tail.load(Ordering::Acquire);
240        if head == tail {
241            return None;
242        }
243        let slot = (head as usize) % RING_CAPACITY;
244        let obs = unsafe { *(*buf.add(slot)).get() };
245        self.head.store(head.wrapping_add(1), Ordering::Release);
246        Some(obs)
247    }
248
249    /// Arm the ring so producers begin pushing observations. Called once
250    /// when a consumer (the sidecar) registers the owning instance. This
251    /// lazily allocates the ~96 KiB observation buffer (zero-filled, i.e.
252    /// all `Observation::ZERO`) and publishes it before setting `armed`,
253    /// so a raw `create()` handle that never arms pays nothing - neither
254    /// the per-push work nor the buffer allocation. Until armed,
255    /// [`push`](Self::push) is a single relaxed load + return. Idempotent;
256    /// the buffer is allocated at most once even under racing callers.
257    pub fn arm(&self) {
258        if self.buf.load(Ordering::Acquire).is_null() {
259            let layout = Self::buf_layout();
260            // SAFETY: layout has non-zero size; alloc_zeroed yields a
261            // valid all-zero block, which is the bit pattern of
262            // `Observation::ZERO` for every slot.
263            let ptr = unsafe { std::alloc::alloc_zeroed(layout) }
264                as *mut core::cell::UnsafeCell<Observation>;
265            if ptr.is_null() {
266                std::alloc::handle_alloc_error(layout);
267            }
268            // Publish. If a concurrent caller won the race, free ours.
269            if self
270                .buf
271                .compare_exchange(
272                    core::ptr::null_mut(),
273                    ptr,
274                    Ordering::AcqRel,
275                    Ordering::Acquire,
276                )
277                .is_err()
278            {
279                unsafe { std::alloc::dealloc(ptr as *mut u8, layout); }
280            }
281        }
282        // Transition to armed and, on the disarmed->armed edge only, bump the
283        // process-global gate so the hot-path guard stops short-circuiting.
284        // `swap` makes the edge atomic under racing arm() callers.
285        if !self.armed.swap(true, Ordering::Release) {
286            ARMED_COUNT.fetch_add(1, Ordering::Relaxed);
287        }
288    }
289
290    /// True once a consumer has armed the ring.
291    #[inline]
292    pub fn is_armed(&self) -> bool {
293        self.armed.load(Ordering::Relaxed)
294    }
295}
296
297impl Default for ObservationRing {
298    fn default() -> Self {
299        Self::new()
300    }
301}
302
303impl Drop for ObservationRing {
304    fn drop(&mut self) {
305        // Balance the arm() increment so the process-global gate returns to 0
306        // once the last armed ring is gone. `get_mut` gives exclusive access.
307        if *self.armed.get_mut() {
308            ARMED_COUNT.fetch_sub(1, Ordering::Relaxed);
309        }
310        // Free the lazily-allocated buffer if this ring was ever armed.
311        let buf = *self.buf.get_mut();
312        if !buf.is_null() {
313            // SAFETY: `buf` was allocated in `arm` with this exact layout
314            // and is owned solely by this ring.
315            unsafe { std::alloc::dealloc(buf as *mut u8, Self::buf_layout()); }
316        }
317    }
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    #[test]
325    fn push_pop_roundtrip() {
326        let ring = ObservationRing::new();
327        ring.arm(); // a ring only accepts pushes once a consumer arms it
328        let obs = Observation { instance_id: 42, op_kind: 1, flags: 0, latency_ticks: 100, producer_thread_id: 0, _reserved: 0 };
329        assert!(ring.push(obs));
330        let got = ring.pop().unwrap();
331        assert_eq!(got.instance_id, 42);
332        assert_eq!(got.op_kind, 1);
333        assert_eq!(got.latency_ticks, 100);
334        // push() auto-stamped the current thread's id.
335        assert_ne!(got.producer_thread_id, 0);
336        assert!(ring.pop().is_none());
337    }
338
339    #[test]
340    fn ring_fills_then_drops() {
341        let ring = ObservationRing::new();
342        ring.arm(); // a ring only accepts pushes once a consumer arms it
343        let obs = Observation::ZERO;
344        for _ in 0..RING_CAPACITY {
345            assert!(ring.push(obs));
346        }
347        assert!(!ring.push(obs));
348    }
349
350    #[test]
351    fn thread_id_stable_across_calls_from_same_thread() {
352        let a = thread_id();
353        let b = thread_id();
354        assert_eq!(a, b);
355        assert_ne!(a, 0, "thread_id must never return 0 (the sentinel)");
356    }
357
358    #[test]
359    fn thread_id_distinct_across_threads() {
360        use std::sync::mpsc;
361        let main_id = thread_id();
362        let (tx, rx) = mpsc::channel();
363        let t1 = std::thread::spawn(move || {
364            tx.send(thread_id()).unwrap();
365        });
366        let id1 = rx.recv().unwrap();
367        t1.join().unwrap();
368        assert_ne!(id1, main_id, "spawned thread must have distinct id");
369        assert_ne!(id1, 0);
370    }
371
372    #[test]
373    fn push_auto_stamps_thread_id_when_zero() {
374        let ring = ObservationRing::new();
375        ring.arm(); // a ring only accepts pushes once a consumer arms it
376        let obs = Observation {
377            instance_id: 1,
378            op_kind: 1,
379            flags: 0,
380            latency_ticks: 0,
381            producer_thread_id: 0,
382            _reserved: 0,
383        };
384        ring.push(obs);
385        let got = ring.pop().unwrap();
386        let me = thread_id();
387        assert_eq!(got.producer_thread_id, me);
388    }
389
390    #[test]
391    fn disarmed_ring_drops_push_until_armed() {
392        // A fresh ring is disarmed: producers skip every push so raw
393        // create() handles pay nothing for observation. push returns
394        // false and nothing is enqueued until a consumer arms it.
395        let ring = ObservationRing::new();
396        assert!(!ring.is_armed());
397        assert!(!ring.push(Observation::ZERO));
398        assert!(ring.pop().is_none());
399        ring.arm();
400        assert!(ring.is_armed());
401        assert!(ring.push(Observation::ZERO));
402        assert!(ring.pop().is_some());
403    }
404
405    #[test]
406    fn push_preserves_explicit_thread_id() {
407        let ring = ObservationRing::new();
408        ring.arm(); // a ring only accepts pushes once a consumer arms it
409        let obs = Observation {
410            instance_id: 1,
411            op_kind: 1,
412            flags: 0,
413            latency_ticks: 0,
414            producer_thread_id: 42,
415            _reserved: 0,
416        };
417        ring.push(obs);
418        let got = ring.pop().unwrap();
419        assert_eq!(got.producer_thread_id, 42, "explicit tid should not be overwritten");
420    }
421}