subetha_sidecar/lib.rs
1//! Sidecar control plane for adaptive primitives.
2//!
3//! One background thread per detected NUMA node; each thread polls
4//! the registered primitive instances bound to its node:
5//!
6//! 1. Drains each instance's [`ObservationRing`] into a per-instance
7//! [`InstanceStats`] accumulator.
8//! 2. Asks the instance's [`Policy`] whether a strategy migration is
9//! warranted.
10//! 3. If yes, calls [`HandshakeHeader::set_tag`] to install the new
11//! strategy.
12//!
13//! Heavy migrations (data swap) are NOT handled here; primitives
14//! that need them invoke their own migration logic from within the
15//! policy callback (e.g. `subetha-cxc::AdaptiveIpc::migrate_to`).
16//!
17//! # Safety model
18//!
19//! Registration takes raw pointers to the user's `HandshakeHeader` and
20//! `ObservationRing`. The contract is:
21//!
22//! - The user must keep these alive until `unregister` returns.
23//! - `unregister` blocks until any in-flight scan finishes, so the user
24//! can drop the underlying memory immediately after.
25//!
26//! The [`SidecarBox<T>`] wrapper enforces this contract by holding a
27//! `Box<T>` (stable address) alongside an auto-unregistering
28//! [`SidecarHandle`].
29
30use std::ptr::NonNull;
31use std::sync::Arc;
32use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
33use std::thread::{self, JoinHandle};
34use std::time::{Duration, Instant};
35
36use subetha_core::{HandshakeHeader, ObservationRing};
37use once_cell::sync::Lazy;
38use parking_lot::{Mutex, RwLock};
39
40pub mod bench_safe;
41
42/// Stable identifier for a registered primitive instance.
43pub type InstanceId = u32;
44
45/// Number of op_kind slots the InstanceStats tracks. Primitives use
46/// `op_kind` values 1..=N to identify per-op-kind buckets (e.g., load
47/// vs store for `AdaptiveCell`; insert/get/remove/snapshot for the
48/// snapshot map). Op kind 0 is reserved for "unspecified".
49pub const N_OP_KINDS: usize = 8;
50
51/// Maximum distinct producer thread ids tracked per op kind. Picked
52/// to be cheap (4*8*4 = 128 bytes per instance) while sufficient for
53/// the policy decisions that read this - once cardinality crosses 1,
54/// the policy migrates regardless of the exact count.
55pub const MAX_TRACKED_THREADS_PER_KIND: usize = 4;
56
57/// Aggregated statistics for one registered instance.
58///
59/// Updated by the sidecar each poll cycle from drained observations.
60#[derive(Debug, Clone, Copy)]
61pub struct InstanceStats {
62 pub ops_observed: u64,
63 pub total_latency_ticks: u64,
64 pub contention_ops: u64,
65 /// Per-op-kind counts. Index by `Observation.op_kind` (clamped to
66 /// the valid range). Primitives that adapt based on a ratio of
67 /// op kinds (e.g., reads vs writes) consume these.
68 pub op_kind_counts: [u64; N_OP_KINDS],
69 pub last_seen_us_ago: u64,
70 /// Number of migrations the sidecar has triggered on this instance
71 /// (apply_migration calls that resulted in a tag change). Used by
72 /// bench harnesses to measure adaptation-latency convergence.
73 pub migrations_triggered: u64,
74 /// Per-op-kind distinct-thread-id cache. Filled lazily by the
75 /// drain as observations arrive. Once a slot fills with a tid
76 /// that doesn't match any earlier slot, the corresponding count
77 /// in `per_op_kind_distinct_count` increments.
78 pub per_op_kind_distinct_threads: [[u32; MAX_TRACKED_THREADS_PER_KIND]; N_OP_KINDS],
79 /// Distinct thread count observed per op_kind (saturates at
80 /// `MAX_TRACKED_THREADS_PER_KIND + 1` - meaning "more than the
81 /// slot table can hold"). `>= 2` is the typical multi-producer
82 /// or multi-consumer detection threshold for primitive policies.
83 pub per_op_kind_distinct_count: [u8; N_OP_KINDS],
84}
85
86impl Default for InstanceStats {
87 fn default() -> Self {
88 Self {
89 ops_observed: 0,
90 total_latency_ticks: 0,
91 contention_ops: 0,
92 op_kind_counts: [0; N_OP_KINDS],
93 last_seen_us_ago: 0,
94 migrations_triggered: 0,
95 per_op_kind_distinct_threads: [[0; MAX_TRACKED_THREADS_PER_KIND]; N_OP_KINDS],
96 per_op_kind_distinct_count: [0; N_OP_KINDS],
97 }
98 }
99}
100
101impl InstanceStats {
102 pub fn average_latency_ticks(&self) -> u64 {
103 self.total_latency_ticks.checked_div(self.ops_observed).unwrap_or(0)
104 }
105
106 pub fn contention_rate(&self) -> f64 {
107 if self.ops_observed == 0 {
108 0.0
109 } else {
110 self.contention_ops as f64 / self.ops_observed as f64
111 }
112 }
113
114 /// Total ops observed across all op kinds. Equals `ops_observed`
115 /// for primitives that always set a non-zero op_kind on their
116 /// observations.
117 pub fn op_kind_total(&self) -> u64 {
118 self.op_kind_counts.iter().sum()
119 }
120
121 /// Ratio of one op kind to the total of two op kinds. Returns 0.0
122 /// when both counts are zero (avoids divide-by-zero in policies).
123 pub fn ratio_of(&self, kind: u16, total_kinds: &[u16]) -> f64 {
124 let k = (kind as usize).min(N_OP_KINDS - 1);
125 let kind_count = self.op_kind_counts[k];
126 let total: u64 = total_kinds.iter()
127 .map(|&i| self.op_kind_counts[(i as usize).min(N_OP_KINDS - 1)])
128 .sum();
129 if total == 0 {
130 0.0
131 } else {
132 kind_count as f64 / total as f64
133 }
134 }
135
136 /// Distinct producer-thread count observed for one op kind.
137 ///
138 /// `>= 2` indicates true multi-producer (or multi-consumer for the
139 /// recv-side op kind) usage on this primitive - the right signal
140 /// for a ChannelPolicy promoting SPSC → MPMC, an AdaptiveCell
141 /// noticing multi-writer churn, etc. Saturates at
142 /// `MAX_TRACKED_THREADS_PER_KIND + 1`.
143 pub fn distinct_threads_for(&self, kind: u16) -> u8 {
144 let k = (kind as usize).min(N_OP_KINDS - 1);
145 self.per_op_kind_distinct_count[k]
146 }
147
148 /// True when the given op kind has been observed from more than
149 /// one distinct producer thread.
150 pub fn is_multi_thread_for(&self, kind: u16) -> bool {
151 self.distinct_threads_for(kind) >= 2
152 }
153}
154
155/// Internal helper: record a thread_id against `(op_kind, stats)`,
156/// updating `per_op_kind_distinct_threads` + `per_op_kind_distinct_count`
157/// when the tid hasn't been seen for that op kind. No-op when tid is 0
158/// (the unspecified sentinel) or the saturation cap is already hit.
159#[inline]
160fn record_thread_for_op(
161 stats: &mut InstanceStats,
162 op_kind: u16,
163 tid: u32,
164) {
165 if tid == 0 {
166 return;
167 }
168 let k = (op_kind as usize).min(N_OP_KINDS - 1);
169 let count = stats.per_op_kind_distinct_count[k];
170 if (count as usize) > MAX_TRACKED_THREADS_PER_KIND {
171 // Already saturated: we know cardinality > MAX_TRACKED; we
172 // don't add slots beyond the cache size, and the count remains
173 // pinned at the saturation value.
174 return;
175 }
176 let slots = &mut stats.per_op_kind_distinct_threads[k];
177 // Linear scan over the populated slots; tids are inserted in
178 // arrival order so the count is exactly the number of populated
179 // slots when below saturation.
180 let n = (count as usize).min(MAX_TRACKED_THREADS_PER_KIND);
181 if slots[..n].contains(&tid) {
182 return;
183 }
184 // New tid: either append to the cache (when there's room) or just
185 // bump the saturated count to MAX+1 (when full).
186 if n < MAX_TRACKED_THREADS_PER_KIND {
187 slots[n] = tid;
188 stats.per_op_kind_distinct_count[k] = (n as u8) + 1;
189 } else {
190 // Saturation transition: count moves from MAX to MAX+1; we
191 // know "more threads than the cache can hold" without
192 // remembering which ones.
193 stats.per_op_kind_distinct_count[k] = (MAX_TRACKED_THREADS_PER_KIND as u8) + 1;
194 }
195}
196
197/// Decides when and how to migrate a primitive instance's strategy.
198///
199/// Called by the sidecar after each scan iteration that observed
200/// at least one new op. Return `Some(new_tag)` to install a new
201/// strategy via [`HandshakeHeader::set_tag`]; `None` to leave it alone.
202pub trait Policy: Send + Sync + 'static {
203 fn decide(&self, stats: &InstanceStats, current_tag: u32) -> Option<u32>;
204}
205
206/// Convenient policy that always returns the same tag (testing).
207pub struct FixedPolicy(pub u32);
208impl Policy for FixedPolicy {
209 fn decide(&self, _stats: &InstanceStats, _current_tag: u32) -> Option<u32> {
210 Some(self.0)
211 }
212}
213
214/// Convenient policy that never migrates (default for primitives that
215/// haven't shipped their adaptation logic yet).
216pub struct NoMigrationPolicy;
217impl Policy for NoMigrationPolicy {
218 fn decide(&self, _stats: &InstanceStats, _current_tag: u32) -> Option<u32> {
219 None
220 }
221}
222
223struct Registration {
224 header: NonNull<HandshakeHeader>,
225 ring: NonNull<ObservationRing>,
226 /// Optional pointer to the registered instance via its trait object.
227 /// When present, the sidecar calls `apply_migration` on it after the
228 /// policy returns a new tag; when absent (raw registration without
229 /// a known instance), the sidecar falls back to `header.set_tag`.
230 instance: Option<NonNull<dyn AdaptiveInstance>>,
231 policy: Box<dyn Policy>,
232 stats: Mutex<InstanceStats>,
233 registered_at: Instant,
234 last_observation_at: Mutex<Option<Instant>>,
235}
236
237// SAFETY: The contract requires `header`, `ring`, and the optional
238// `instance` pointer to be valid for the lifetime of this Registration
239// (i.e., until unregister returns). The sidecar accesses them only
240// while holding a read lock on the instances vec, which blocks
241// unregister.
242unsafe impl Send for Registration {}
243unsafe impl Sync for Registration {}
244
245/// Safety cap on drained observations per scan iteration per instance.
246///
247/// In normal operation the sidecar drains the entire ring on each scan
248/// (no sampling bias from FIFO order) - the ring's natural capacity
249/// (4096 slots) bounds the work. This cap is the catastrophe-mode
250/// limit: if a misconfigured ring ever exceeds this number of slots,
251/// the cap kicks in to keep one busy instance from starving the
252/// others.
253///
254/// Worst-case per-scan cost: 4096 observations * ~10 ns drain cost =
255/// ~40 us per instance, holding the read lock that long. With 100
256/// instances this caps a single scan loop at ~4 ms.
257const DRAIN_SAFETY_CAP: usize = 8192;
258
259/// Sidecar poll interval. Trade-off: shorter = faster reaction to
260/// transitions; longer = less CPU spent on cold/idle instances.
261const POLL_INTERVAL: Duration = Duration::from_micros(200);
262
263/// A single node's instance vec + scanning thread. One per NUMA node.
264struct NodeSidecar {
265 instances: RwLock<Vec<Option<Registration>>>,
266}
267
268impl NodeSidecar {
269 fn new() -> Self {
270 Self { instances: RwLock::new(Vec::new()) }
271 }
272}
273
274/// The sidecar singleton. Internally a pool of one `NodeSidecar` per
275/// detected NUMA node; each node has its own scanning thread + Vec of
276/// registered primitive instances. Registration routes by
277/// `current_numa_node()` so cross-NUMA cache traffic on the scan path
278/// stays minimal. InstanceId encodes (node_index, slot) so unregister
279/// + stats can find the right node's vec.
280pub struct Sidecar {
281 nodes: Vec<NodeSidecar>,
282 shutdown: Arc<AtomicBool>,
283 join_handles: Mutex<Vec<JoinHandle<()>>>,
284 /// Currently-registered instance count (monotonic over the lifetime
285 /// of this Sidecar). Incremented in `register_raw`, decremented in
286 /// `unregister`. Read via [`Sidecar::instance_count`].
287 instance_count: AtomicUsize,
288 /// Hard cap on simultaneously-registered instances. When
289 /// `register_raw` would cross this, it panics with a diagnostic.
290 /// The default ([`DEFAULT_MAX_INSTANCES`]) covers all realistic
291 /// production workloads; raise via [`Sidecar::set_max_instances`]
292 /// when intentional heavy registration is needed.
293 max_instances: AtomicUsize,
294}
295
296/// Default hard cap on registered instances. Sized to fail fast on
297/// the "bench creates a SidecarBox per `b.iter()`" mistake, which
298/// exhausts the host near 94k registrations: this cap refuses an
299/// order of magnitude before that, while leaving room above the
300/// 10..1000 range production workloads sit in.
301pub const DEFAULT_MAX_INSTANCES: usize = 10_000;
302
303/// Number of bits in InstanceId reserved for the node index (upper).
304const NODE_ID_BITS: u32 = 8;
305/// Mask for the slot portion of InstanceId (lower).
306const SLOT_MASK: u32 = (1 << (32 - NODE_ID_BITS)) - 1;
307
308fn pack_id(node: u32, slot: u32) -> InstanceId {
309 (node << (32 - NODE_ID_BITS)) | (slot & SLOT_MASK)
310}
311
312fn unpack_id(id: InstanceId) -> (u32, u32) {
313 (id >> (32 - NODE_ID_BITS), id & SLOT_MASK)
314}
315
316impl Sidecar {
317 fn new() -> Arc<Self> {
318 let shutdown = Arc::new(AtomicBool::new(false));
319 let num_nodes = numa_node_count().max(1) as usize;
320 let mut nodes = Vec::with_capacity(num_nodes);
321 for _ in 0..num_nodes {
322 nodes.push(NodeSidecar::new());
323 }
324 let sidecar = Arc::new(Self {
325 nodes,
326 shutdown: shutdown.clone(),
327 join_handles: Mutex::new(Vec::with_capacity(num_nodes)),
328 instance_count: AtomicUsize::new(0),
329 max_instances: AtomicUsize::new(DEFAULT_MAX_INSTANCES),
330 });
331
332 let mut handles = Vec::with_capacity(num_nodes);
333 for node_idx in 0..num_nodes {
334 let runner = sidecar.clone();
335 let handle = thread::Builder::new()
336 .name(format!("subetha-sidecar-node{node_idx}"))
337 .spawn(move || runner.run_loop_for_node(node_idx))
338 .expect("failed to spawn subetha-sidecar node thread");
339 handles.push(handle);
340 }
341 *sidecar.join_handles.lock() = handles;
342
343 sidecar
344 }
345
346 fn run_loop_for_node(self: Arc<Self>, node_idx: usize) {
347 while !self.shutdown.load(Ordering::Acquire) {
348 self.scan_node(node_idx);
349 thread::sleep(POLL_INTERVAL);
350 }
351 }
352
353 fn scan_node(&self, node_idx: usize) {
354 let Some(node) = self.nodes.get(node_idx) else { return };
355 let guard = node.instances.read();
356 Self::scan_instances(&guard);
357 }
358
359 fn scan_instances(instances: &[Option<Registration>]) {
360 for reg_opt in instances.iter() {
361 let Some(reg) = reg_opt else { continue };
362 let ring = unsafe { reg.ring.as_ref() };
363 let header = unsafe { reg.header.as_ref() };
364 let mut drained_ops: u64 = 0;
365 let mut drained_lat: u64 = 0;
366 let mut drained_cont: u64 = 0;
367 let mut drained_kinds: [u64; N_OP_KINDS] = [0; N_OP_KINDS];
368 // Per-scan dedupe of (op_kind, tid) pairs. Bounded at
369 // N_OP_KINDS * MAX_TRACKED_THREADS_PER_KIND so even a
370 // burst of distinct threads costs O(constant) per scan
371 // instead of saturating the stats-lock window.
372 const DEDUPE_CAP: usize = N_OP_KINDS * MAX_TRACKED_THREADS_PER_KIND;
373 let mut tid_dedupe: [(u16, u32); DEDUPE_CAP] = [(0, 0); DEDUPE_CAP];
374 let mut tid_dedupe_len: usize = 0;
375 for _ in 0..DRAIN_SAFETY_CAP {
376 let Some(obs) = ring.pop() else { break };
377 drained_ops += 1;
378 drained_lat = drained_lat.saturating_add(obs.latency_ticks);
379 if obs.flags & 1 != 0 {
380 drained_cont += 1;
381 }
382 let k = (obs.op_kind as usize).min(N_OP_KINDS - 1);
383 drained_kinds[k] = drained_kinds[k].saturating_add(1);
384 // Inline dedupe of (op_kind, tid) pairs.
385 if obs.producer_thread_id != 0 && tid_dedupe_len < DEDUPE_CAP {
386 let pair = (obs.op_kind, obs.producer_thread_id);
387 let seen = tid_dedupe[..tid_dedupe_len].contains(&pair);
388 if !seen {
389 tid_dedupe[tid_dedupe_len] = pair;
390 tid_dedupe_len += 1;
391 }
392 }
393 }
394 if drained_ops == 0 {
395 continue;
396 }
397 let stats_snapshot = {
398 let mut s = reg.stats.lock();
399 s.ops_observed = s.ops_observed.saturating_add(drained_ops);
400 s.total_latency_ticks = s.total_latency_ticks.saturating_add(drained_lat);
401 s.contention_ops = s.contention_ops.saturating_add(drained_cont);
402 for (slot, drained) in s.op_kind_counts.iter_mut().zip(drained_kinds.iter()) {
403 *slot = slot.saturating_add(*drained);
404 }
405 // Fold deduped (op_kind, tid) pairs into per-op-kind
406 // distinct-thread tracking on the stats struct.
407 for &(op_kind, tid) in tid_dedupe[..tid_dedupe_len].iter() {
408 record_thread_for_op(&mut s, op_kind, tid);
409 }
410 let now = Instant::now();
411 *reg.last_observation_at.lock() = Some(now);
412 s.last_seen_us_ago = now
413 .duration_since(reg.registered_at)
414 .as_micros() as u64;
415 *s
416 };
417 let current_tag = header.tag();
418 if let Some(new_tag) = reg.policy.decide(&stats_snapshot, current_tag)
419 && new_tag != current_tag {
420 if let Some(inst_ptr) = reg.instance {
421 let inst = unsafe { &*inst_ptr.as_ptr() };
422 inst.apply_migration(new_tag);
423 } else {
424 header.set_tag(new_tag);
425 }
426 let mut s = reg.stats.lock();
427 s.migrations_triggered = s.migrations_triggered.saturating_add(1);
428 }
429 }
430 }
431
432 /// Register a primitive instance.
433 ///
434 /// # Safety
435 ///
436 /// `header`, `ring`, and (when provided) `instance` must remain
437 /// valid until `unregister(id)` returns for the returned `id`.
438 /// Prefer [`SidecarBox`] which enforces this invariant automatically.
439 pub unsafe fn register_raw(
440 &self,
441 header: NonNull<HandshakeHeader>,
442 ring: NonNull<ObservationRing>,
443 instance: Option<NonNull<dyn AdaptiveInstance>>,
444 policy: Box<dyn Policy>,
445 ) -> InstanceId {
446 // Hard cap enforced before any allocation. Panics with a
447 // diagnostic identifying the likely cause; the diagnostic
448 // text is part of the API surface and tested below.
449 let cap = self.max_instances.load(Ordering::Acquire);
450 let prev = self.instance_count.fetch_add(1, Ordering::AcqRel);
451 if prev >= cap {
452 self.instance_count.fetch_sub(1, Ordering::AcqRel);
453 panic!(
454 "subetha-sidecar: instance cap ({cap}) exceeded.\n\
455 Likely cause: SidecarBox<Adaptive*> is being created \
456 inside a tight loop (criterion b.iter(), test fixture, \
457 or runaway production code). Move construction outside \
458 the loop and reuse the instance, or call \
459 Sidecar::set_max_instances() if the load is intentional."
460 );
461 }
462 // Arm the ring now that a consumer (this sidecar) is taking
463 // ownership of draining it. Until this point producers skip every
464 // push, so raw `create()` handles pay nothing for observation.
465 // SAFETY: `ring` is valid per this function's safety contract.
466 unsafe { ring.as_ref().arm(); }
467
468 let reg = Registration {
469 header,
470 ring,
471 instance,
472 policy,
473 stats: Mutex::new(InstanceStats::default()),
474 registered_at: Instant::now(),
475 last_observation_at: Mutex::new(None),
476 };
477
478 // Route by current NUMA node; clamp to available nodes.
479 let node_idx = (current_numa_node() as usize) % self.nodes.len();
480 let node = &self.nodes[node_idx];
481 let mut guard = node.instances.write();
482
483 // Find a vacant slot or push at the end.
484 for (slot_idx, slot) in guard.iter_mut().enumerate() {
485 if slot.is_none() {
486 *slot = Some(reg);
487 return pack_id(node_idx as u32, slot_idx as u32);
488 }
489 }
490 let slot_idx = guard.len();
491 guard.push(Some(reg));
492 pack_id(node_idx as u32, slot_idx as u32)
493 }
494
495 /// Remove a registered instance.
496 ///
497 /// Blocks until any in-flight scan iteration finishes, so the caller
498 /// can safely drop the underlying header/ring memory immediately
499 /// after this returns.
500 pub fn unregister(&self, id: InstanceId) {
501 let (node_idx, slot_idx) = unpack_id(id);
502 let Some(node) = self.nodes.get(node_idx as usize) else { return };
503 let mut guard = node.instances.write();
504 if let Some(slot) = guard.get_mut(slot_idx as usize)
505 && slot.is_some() {
506 *slot = None;
507 self.instance_count.fetch_sub(1, Ordering::AcqRel);
508 }
509 }
510
511 /// Currently-registered instance count.
512 pub fn instance_count(&self) -> usize {
513 self.instance_count.load(Ordering::Acquire)
514 }
515
516 /// Configured maximum simultaneously-registered instances. See
517 /// [`DEFAULT_MAX_INSTANCES`] for the default and
518 /// [`Self::set_max_instances`] to change it.
519 pub fn max_instances(&self) -> usize {
520 self.max_instances.load(Ordering::Acquire)
521 }
522
523 /// Raise or lower the instance cap. Intentional heavy-registration
524 /// workloads (e.g., a server that legitimately wants > 10,000
525 /// adaptive primitives live at once) should call this once at
526 /// startup. The cap is per-process; the global Sidecar inherits
527 /// it via [`global()`].
528 pub fn set_max_instances(&self, cap: usize) {
529 self.max_instances.store(cap, Ordering::Release);
530 }
531
532 /// Snapshot the stats for a registered instance.
533 pub fn stats(&self, id: InstanceId) -> Option<InstanceStats> {
534 let (node_idx, slot_idx) = unpack_id(id);
535 let node = self.nodes.get(node_idx as usize)?;
536 let guard = node.instances.read();
537 guard.get(slot_idx as usize)?.as_ref().map(|r| *r.stats.lock())
538 }
539
540 /// Force one scan iteration synchronously across all NUMA nodes.
541 /// Useful for tests where we don't want to wait for the poll interval.
542 pub fn scan_now(&self) {
543 for node_idx in 0..self.nodes.len() {
544 self.scan_node(node_idx);
545 }
546 }
547
548 /// Number of NUMA-pinned sidecar threads in this pool.
549 pub fn node_count(&self) -> usize {
550 self.nodes.len()
551 }
552
553}
554
555impl Drop for Sidecar {
556 fn drop(&mut self) {
557 self.shutdown.store(true, Ordering::Release);
558 let handles: Vec<JoinHandle<()>> = std::mem::take(&mut *self.join_handles.lock());
559 for h in handles {
560 // Worker panic on shutdown is non-fatal.
561 h.join().ok();
562 }
563 }
564}
565
566static GLOBAL: Lazy<Arc<Sidecar>> = Lazy::new(|| {
567 let s = Sidecar::new();
568 // Register an `atexit` callback that signals shutdown + joins
569 // the sidecar threads before process teardown. Without this, the
570 // `static Lazy<Arc<Sidecar>>` never drops at exit (Rust statics
571 // with non-trivial Drop aren't run for late-initialised Lazy);
572 // the OS terminates sidecar threads mid-action, occasionally
573 // producing STATUS_ACCESS_VIOLATION at process exit when their
574 // parking_lot/crossbeam TLS state races with the main thread's
575 // CRT shutdown.
576 register_sidecar_atexit();
577 s
578});
579
580/// One-shot registration of the atexit callback. Idempotent across
581/// processes that re-initialise the Lazy (e.g., on fork + re-exec).
582fn register_sidecar_atexit() {
583 static REGISTERED: std::sync::Once = std::sync::Once::new();
584 REGISTERED.call_once(|| {
585 // SAFETY: `atexit` accepts an `extern "C" fn()` callback that
586 // the CRT invokes from the main thread during normal process
587 // teardown (after `main` returns, before final OS exit). The
588 // callback we register only touches `GLOBAL` (a static),
589 // which outlives the call by construction.
590 unsafe {
591 unsafe extern "C" {
592 fn atexit(cb: extern "C" fn()) -> i32;
593 }
594 atexit(sidecar_atexit_shutdown);
595 }
596 });
597}
598
599/// atexit-registered callback: signal sidecar shutdown + join the
600/// per-NUMA scanning threads. Runs on the main thread during normal
601/// process teardown so the OS doesn't have to TerminateThread the
602/// sidecar workers mid-action.
603extern "C" fn sidecar_atexit_shutdown() {
604 if let Some(sidecar) = Lazy::get(&GLOBAL) {
605 sidecar.shutdown.store(true, Ordering::Release);
606 let handles: Vec<JoinHandle<()>> = std::mem::take(
607 &mut *sidecar.join_handles.lock(),
608 );
609 for h in handles {
610 h.join().ok();
611 }
612 // After joining the scan threads, also clear the registry so
613 // that any other static drop chain that touches Sidecar sees
614 // an empty state instead of dangling raw pointers from leaked
615 // SidecarBox<T> instances. (Tests may leak via panic; tear-
616 // down code must tolerate it.)
617 for node in &sidecar.nodes {
618 let mut g = node.instances.write();
619 for slot in g.iter_mut() {
620 *slot = None;
621 }
622 }
623 eprintln!("[subetha-sidecar atexit] shutdown complete");
624 }
625}
626
627/// Get the process-wide sidecar singleton.
628pub fn global() -> Arc<Sidecar> {
629 GLOBAL.clone()
630}
631
632/// Number of NUMA nodes detected on this host. Used by the (in-progress)
633/// per-NUMA sidecar sharding to decide how many sidecar threads to spawn.
634///
635/// On Windows this calls `GetNumaHighestNodeNumber`. On other platforms
636/// it returns 1 (no NUMA awareness). Returns at least 1.
637pub fn numa_node_count() -> u32 {
638 #[cfg(target_os = "windows")]
639 {
640 // SAFETY: GetNumaHighestNodeNumber takes a pointer to a ULONG
641 // and writes the highest node number through it. No allocation.
642 unsafe {
643 let mut highest: u32 = 0;
644 unsafe extern "system" {
645 fn GetNumaHighestNodeNumber(HighestNodeNumber: *mut u32) -> i32;
646 }
647 // Link against kernel32.lib (auto-linked on MSVC targets).
648 let result = GetNumaHighestNodeNumber(&mut highest);
649 if result == 0 {
650 // BOOL FALSE means failure; fall back to 1 node.
651 1
652 } else {
653 highest.saturating_add(1)
654 }
655 }
656 }
657 #[cfg(not(target_os = "windows"))]
658 {
659 1
660 }
661}
662
663/// Per-NUMA-node sidecar sharding scaffolding. The default global
664/// sidecar handles all instances; multi-sidecar deployment with
665/// per-NUMA pinning would spawn one Sidecar per node and route
666/// registrations by spawn-thread affinity. The detection function
667/// [`numa_node_count`] surfaces the topology; the routing layer plugs
668/// in here when load testing on a multi-socket host motivates it.
669///
670/// Uses `GetCurrentProcessorNumberEx` + `GetNumaProcessorNodeEx` on
671/// Windows: these two work across processor groups (Windows splits
672/// logical processors into groups of up to 64), so the >64-logical-
673/// processor case (dual-socket servers, large core-count workstations)
674/// is handled correctly. The legacy `GetNumaProcessorNode` (capped at
675/// processor 255) is no longer called.
676pub fn current_numa_node() -> u32 {
677 #[cfg(target_os = "windows")]
678 {
679 // PROCESSOR_NUMBER per
680 // https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-processor_number
681 // sized layout: Group (USHORT) + Number (BYTE) + Reserved (BYTE) = 4 bytes.
682 #[repr(C)]
683 #[derive(Default, Clone, Copy)]
684 struct ProcessorNumber {
685 group: u16,
686 number: u8,
687 reserved: u8,
688 }
689 // SAFETY: GetCurrentProcessorNumberEx writes through &mut, no allocation.
690 // GetNumaProcessorNodeEx reads from the struct and writes one u16 out.
691 unsafe {
692 unsafe extern "system" {
693 fn GetCurrentProcessorNumberEx(ProcNumber: *mut ProcessorNumber);
694 fn GetNumaProcessorNodeEx(
695 Processor: *const ProcessorNumber,
696 NodeNumber: *mut u16,
697 ) -> i32;
698 }
699 let mut proc = ProcessorNumber::default();
700 GetCurrentProcessorNumberEx(&mut proc);
701 let mut node: u16 = 0;
702 if GetNumaProcessorNodeEx(&proc, &mut node) != 0 {
703 // u16::MAX (0xFFFF) is a documented "no node" sentinel
704 // returned by the API for un-NUMA-classified procs;
705 // route those to node 0.
706 if node == u16::MAX { 0 } else { node as u32 }
707 } else {
708 0
709 }
710 }
711 }
712 #[cfg(not(target_os = "windows"))]
713 {
714 // Linux: read /sys/devices/system/cpu/cpu<id>/topology/physical_package_id
715 // for the current CPU. sched_getcpu() returns the logical CPU index;
716 // /sys exposes the NUMA mapping. Fall back to node 0 if any step
717 // fails (no sysfs, container without /sys, etc).
718 current_numa_node_linux()
719 }
720}
721
722#[cfg(not(target_os = "windows"))]
723fn current_numa_node_linux() -> u32 {
724 use std::fs;
725 // libc::sched_getcpu would be the direct call but we avoid the libc
726 // dep by reading /proc/self/stat field 39 (last_cpu) or by parsing
727 // /sys/.../cpu<N>/topology. For portability across kernels we read
728 // /proc/self/stat which exposes the last-scheduled CPU.
729 let stat = match fs::read_to_string("/proc/self/stat") {
730 Ok(s) => s,
731 Err(_) => return 0,
732 };
733 // /proc/self/stat fields are space-separated AFTER the comm field
734 // (which is parenthesised). Skip past the closing paren.
735 let after_comm = match stat.rfind(')') {
736 Some(i) => &stat[i + 1..],
737 None => return 0,
738 };
739 // Last-scheduled CPU is field 39 in proc(5); we count fields from
740 // after_comm (which is at field 3 boundary because pid, comm are 1-2).
741 let cpu = match after_comm.split_whitespace().nth(36) {
742 Some(s) => match s.parse::<u32>() { Ok(v) => v, Err(_) => return 0 },
743 None => return 0,
744 };
745 let path = format!(
746 "/sys/devices/system/cpu/cpu{cpu}/topology/physical_package_id"
747 );
748 match fs::read_to_string(&path) {
749 Ok(s) => s.trim().parse::<u32>().unwrap_or(0),
750 Err(_) => 0,
751 }
752}
753
754/// RAII handle that auto-unregisters its instance on drop.
755pub struct SidecarHandle {
756 id: InstanceId,
757 sidecar: Arc<Sidecar>,
758}
759
760impl SidecarHandle {
761 pub fn id(&self) -> InstanceId {
762 self.id
763 }
764
765 pub fn stats(&self) -> Option<InstanceStats> {
766 self.sidecar.stats(self.id)
767 }
768}
769
770impl Drop for SidecarHandle {
771 fn drop(&mut self) {
772 self.sidecar.unregister(self.id);
773 }
774}
775
776/// Trait implemented by adaptive primitive instances that opt into
777/// sidecar observation. The Box guarantees stable addresses for the
778/// header and ring.
779pub trait AdaptiveInstance: Send + Sync + 'static {
780 fn header(&self) -> &HandshakeHeader;
781 fn ring(&self) -> &ObservationRing;
782 fn make_policy(&self) -> Box<dyn Policy>;
783
784 /// Called by the sidecar when the policy returns a new strategy
785 /// tag. Default implementation: just set the tag on the header.
786 /// Primitives that need heavier migration (data-layout swap)
787 /// override this to perform the swap before (or after) updating
788 /// the tag.
789 fn apply_migration(&self, new_tag: u32) {
790 self.header().set_tag(new_tag);
791 }
792}
793
794/// Boxed primitive + auto-unregistering sidecar handle.
795///
796/// `Drop` order is well-defined: handle drops first (blocks on scan,
797/// then clears the registry slot), then the box drops (frees the
798/// header/ring memory). No raw-pointer-after-free race.
799pub struct SidecarBox<T: AdaptiveInstance> {
800 // ORDER MATTERS: handle drops before inner.
801 handle: SidecarHandle,
802 inner: Box<T>,
803}
804
805impl<T: AdaptiveInstance> SidecarBox<T> {
806 pub fn new(value: T) -> Self {
807 let inner = Box::new(value);
808 // SAFETY: Box guarantees stable address until inner is dropped.
809 // Field references and the instance pointer are valid as long
810 // as inner is alive. SidecarHandle::drop runs before inner::drop,
811 // calling unregister(), which blocks until any in-flight scan
812 // finishes.
813 let header = NonNull::from(inner.header());
814 let ring = NonNull::from(inner.ring());
815 let instance_ref: &dyn AdaptiveInstance = &*inner;
816 let instance_ptr: *const dyn AdaptiveInstance = instance_ref;
817 let instance = unsafe {
818 NonNull::new_unchecked(instance_ptr as *mut dyn AdaptiveInstance)
819 };
820 let policy = inner.make_policy();
821 let sidecar = global();
822 let id = unsafe { sidecar.register_raw(header, ring, Some(instance), policy) };
823 Self {
824 handle: SidecarHandle { id, sidecar },
825 inner,
826 }
827 }
828
829 pub fn id(&self) -> InstanceId {
830 self.handle.id
831 }
832
833 pub fn stats(&self) -> Option<InstanceStats> {
834 self.handle.stats()
835 }
836}
837
838impl<T: AdaptiveInstance> std::ops::Deref for SidecarBox<T> {
839 type Target = T;
840 fn deref(&self) -> &T {
841 &self.inner
842 }
843}
844
845#[cfg(test)]
846mod tests {
847 use super::*;
848 use subetha_core::Observation;
849
850 /// A bare instance with just header + ring for sidecar testing.
851 struct BareInstance {
852 header: HandshakeHeader,
853 ring: ObservationRing,
854 }
855
856 impl BareInstance {
857 fn new() -> Self {
858 Self {
859 header: HandshakeHeader::new(),
860 ring: ObservationRing::new(),
861 }
862 }
863 }
864
865 impl AdaptiveInstance for BareInstance {
866 fn header(&self) -> &HandshakeHeader { &self.header }
867 fn ring(&self) -> &ObservationRing { &self.ring }
868 fn make_policy(&self) -> Box<dyn Policy> { Box::new(NoMigrationPolicy) }
869 }
870
871 /// Policy that escalates the tag whenever average latency > threshold.
872 struct EscalatingPolicy {
873 threshold_ticks: u64,
874 escalate_to: u32,
875 }
876
877 impl Policy for EscalatingPolicy {
878 fn decide(&self, stats: &InstanceStats, current_tag: u32) -> Option<u32> {
879 if stats.average_latency_ticks() > self.threshold_ticks && current_tag < self.escalate_to {
880 Some(self.escalate_to)
881 } else {
882 None
883 }
884 }
885 }
886
887 struct EscalatingInstance {
888 header: HandshakeHeader,
889 ring: ObservationRing,
890 }
891
892 impl EscalatingInstance {
893 fn new() -> Self {
894 Self {
895 header: HandshakeHeader::new(),
896 ring: ObservationRing::new(),
897 }
898 }
899 }
900
901 impl AdaptiveInstance for EscalatingInstance {
902 fn header(&self) -> &HandshakeHeader { &self.header }
903 fn ring(&self) -> &ObservationRing { &self.ring }
904 fn make_policy(&self) -> Box<dyn Policy> {
905 Box::new(EscalatingPolicy {
906 threshold_ticks: 500,
907 escalate_to: 2,
908 })
909 }
910 }
911
912 #[test]
913 fn register_unregister_balances() {
914 let s = global();
915 let inst = Box::new(BareInstance::new());
916 let header = NonNull::from(inst.header());
917 let ring = NonNull::from(inst.ring());
918 let id = unsafe {
919 s.register_raw(header, ring, None, Box::new(NoMigrationPolicy))
920 };
921 assert!(s.stats(id).is_some());
922 s.unregister(id);
923 assert!(s.stats(id).is_none());
924 drop(inst);
925 }
926
927 #[test]
928 fn sidecar_drains_observations() {
929 let inst = SidecarBox::new(BareInstance::new());
930
931 // Push 10 observations.
932 for i in 0..10 {
933 assert!(inst.ring.push(Observation {
934 instance_id: 0,
935 op_kind: 1,
936 flags: 0,
937 latency_ticks: 100 + i,
938 ..Observation::ZERO
939 }));
940 }
941
942 // Force a scan.
943 global().scan_now();
944
945 let stats = inst.stats().expect("instance should be registered");
946 assert_eq!(stats.ops_observed, 10);
947 assert!(stats.total_latency_ticks >= 1000);
948 }
949
950 #[test]
951 fn policy_migrates_strategy_when_threshold_crossed() {
952 let inst = SidecarBox::new(EscalatingInstance::new());
953 assert_eq!(inst.header().tag(), 0);
954
955 // Push observations with latency well above threshold (500).
956 for _ in 0..50 {
957 inst.ring.push(Observation {
958 instance_id: 0,
959 op_kind: 1,
960 flags: 0,
961 latency_ticks: 5000,
962 ..Observation::ZERO
963 });
964 }
965
966 global().scan_now();
967
968 // EscalatingPolicy should have set tag to 2.
969 assert_eq!(inst.header().tag(), 2,
970 "policy should have escalated tag to 2 after high-latency observations");
971 }
972
973 #[test]
974 fn unregister_blocks_safe_drop() {
975 // This is the load-bearing race-safety test. We register an
976 // instance, push observations, drop the SidecarBox while the
977 // sidecar may be mid-scan, and rely on the unregister-blocks-on-
978 // scan contract to prevent use-after-free.
979 for _ in 0..50 {
980 let inst = SidecarBox::new(BareInstance::new());
981 // Push observations to make the sidecar dereference our pointers.
982 for _ in 0..100 {
983 inst.ring.push(Observation {
984 instance_id: 0,
985 op_kind: 1,
986 flags: 0,
987 latency_ticks: 10,
988 ..Observation::ZERO
989 });
990 }
991 // Drop while sidecar may be scanning. If unregister doesn't
992 // block correctly, this leads to use-after-free under TSAN/ASAN.
993 drop(inst);
994 }
995 }
996
997 #[test]
998 fn fixed_policy_sets_tag_immediately() {
999 struct Inst { h: HandshakeHeader, r: ObservationRing }
1000 impl AdaptiveInstance for Inst {
1001 fn header(&self) -> &HandshakeHeader { &self.h }
1002 fn ring(&self) -> &ObservationRing { &self.r }
1003 fn make_policy(&self) -> Box<dyn Policy> { Box::new(FixedPolicy(7)) }
1004 }
1005 let inst = SidecarBox::new(Inst {
1006 h: HandshakeHeader::new(),
1007 r: ObservationRing::new(),
1008 });
1009
1010 inst.r.push(Observation { instance_id: 0, op_kind: 0, flags: 0, latency_ticks: 1, ..Observation::ZERO });
1011 global().scan_now();
1012 assert_eq!(inst.h.tag(), 7);
1013 }
1014
1015 #[test]
1016 fn instance_count_tracks_register_and_unregister() {
1017 // Use a local Sidecar so this test does not interfere with
1018 // the global one used by other tests.
1019 let s = Sidecar::new();
1020 let start = s.instance_count();
1021
1022 let inst = Box::new(BareInstance::new());
1023 let header = NonNull::from(inst.header());
1024 let ring = NonNull::from(inst.ring());
1025 let id = unsafe {
1026 s.register_raw(header, ring, None, Box::new(NoMigrationPolicy))
1027 };
1028 assert_eq!(s.instance_count(), start + 1);
1029
1030 s.unregister(id);
1031 assert_eq!(s.instance_count(), start);
1032 }
1033
1034 #[test]
1035 fn cap_panic_message_is_actionable() {
1036 // Build a Sidecar with a tiny cap and verify the panic
1037 // message names the actual cap value AND mentions the
1038 // diagnostic guidance about loops / b.iter() / set_max_instances.
1039 let s = Sidecar::new();
1040 s.set_max_instances(2);
1041 assert_eq!(s.max_instances(), 2);
1042
1043 // Register up to the cap (no panic).
1044 let inst1 = Box::new(BareInstance::new());
1045 let id1 = unsafe {
1046 s.register_raw(
1047 NonNull::from(inst1.header()),
1048 NonNull::from(inst1.ring()),
1049 None,
1050 Box::new(NoMigrationPolicy),
1051 )
1052 };
1053 let inst2 = Box::new(BareInstance::new());
1054 let id2 = unsafe {
1055 s.register_raw(
1056 NonNull::from(inst2.header()),
1057 NonNull::from(inst2.ring()),
1058 None,
1059 Box::new(NoMigrationPolicy),
1060 )
1061 };
1062
1063 // Third must panic with the documented diagnostic.
1064 let inst3 = Box::new(BareInstance::new());
1065 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1066 unsafe {
1067 s.register_raw(
1068 NonNull::from(inst3.header()),
1069 NonNull::from(inst3.ring()),
1070 None,
1071 Box::new(NoMigrationPolicy),
1072 )
1073 }
1074 }));
1075 let payload = result.expect_err("must panic when over cap");
1076 let msg = payload.downcast_ref::<String>().map(String::as_str)
1077 .or_else(|| payload.downcast_ref::<&'static str>().copied())
1078 .expect("panic payload must be a string");
1079 assert!(msg.contains("instance cap (2) exceeded"),
1080 "panic must name the cap value: {msg}");
1081 assert!(msg.contains("b.iter()") || msg.contains("loop"),
1082 "panic must hint at b.iter() / loop misuse: {msg}");
1083 assert!(msg.contains("set_max_instances"),
1084 "panic must mention the escape hatch: {msg}");
1085
1086 // Failed register must NOT have incremented the count past cap.
1087 assert_eq!(s.instance_count(), 2,
1088 "count must roll back on cap-rejected register");
1089
1090 // Cleanup so test does not leak.
1091 s.unregister(id1);
1092 s.unregister(id2);
1093 }
1094}