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}