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_TRACKED_THREADS_PER_KIND + 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 the cap to cap + 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 // A scan worker that panicked is reported: a Drop has no
561 // caller to hand it to, and a panic here would abort an
562 // unwind already in progress.
563 if h.join().is_err() {
564 eprintln!("subetha-sidecar: a scan worker ended by panic before shutdown");
565 }
566 }
567 }
568}
569
570static GLOBAL: Lazy<Arc<Sidecar>> = Lazy::new(|| {
571 let s = Sidecar::new();
572 // Register an `atexit` callback that signals shutdown + joins
573 // the sidecar threads before process teardown. Without this, the
574 // `static Lazy<Arc<Sidecar>>` never drops at exit (Rust statics
575 // with non-trivial Drop aren't run for late-initialized Lazy);
576 // the OS terminates sidecar threads mid-action, occasionally
577 // producing STATUS_ACCESS_VIOLATION at process exit when their
578 // parking_lot/crossbeam TLS state races with the main thread's
579 // CRT shutdown.
580 register_sidecar_atexit();
581 s
582});
583
584/// One-shot registration of the atexit callback. Idempotent across
585/// processes that re-initialize the Lazy (e.g., on fork + re-exec).
586fn register_sidecar_atexit() {
587 static REGISTERED: std::sync::Once = std::sync::Once::new();
588 REGISTERED.call_once(|| {
589 // SAFETY: `atexit` accepts an `extern "C" fn()` callback that
590 // the CRT invokes from the main thread during normal process
591 // teardown (after `main` returns, before final OS exit). The
592 // callback we register only touches `GLOBAL` (a static),
593 // which outlives the call by construction.
594 unsafe {
595 unsafe extern "C" {
596 fn atexit(cb: extern "C" fn()) -> i32;
597 }
598 atexit(sidecar_atexit_shutdown);
599 }
600 });
601}
602
603/// atexit-registered callback: signal sidecar shutdown + join the
604/// per-NUMA scanning threads. Runs on the main thread during normal
605/// process teardown so the OS doesn't have to TerminateThread the
606/// sidecar workers mid-action.
607extern "C" fn sidecar_atexit_shutdown() {
608 if let Some(sidecar) = Lazy::get(&GLOBAL) {
609 sidecar.shutdown.store(true, Ordering::Release);
610 let handles: Vec<JoinHandle<()>> = std::mem::take(
611 &mut *sidecar.join_handles.lock(),
612 );
613 for h in handles {
614 // A scan worker that panicked is reported; process teardown
615 // has no caller to hand it to.
616 if h.join().is_err() {
617 eprintln!("subetha-sidecar: a scan worker ended by panic before process exit");
618 }
619 }
620 // After joining the scan threads, also clear the registry so
621 // that any other static drop chain that touches Sidecar sees
622 // an empty state instead of dangling raw pointers from leaked
623 // SidecarBox<T> instances. (Tests may leak via panic; tear-
624 // down code must tolerate it.)
625 for node in &sidecar.nodes {
626 let mut g = node.instances.write();
627 for slot in g.iter_mut() {
628 *slot = None;
629 }
630 }
631 eprintln!("[subetha-sidecar atexit] shutdown complete");
632 }
633}
634
635/// Get the process-wide sidecar singleton.
636pub fn global() -> Arc<Sidecar> {
637 GLOBAL.clone()
638}
639
640/// Number of NUMA nodes detected on this host. Used by the (in-progress)
641/// per-NUMA sidecar sharding to decide how many sidecar threads to spawn.
642///
643/// On Windows this calls `GetNumaHighestNodeNumber`. On other platforms
644/// it returns 1 (no NUMA awareness). Returns at least 1.
645pub fn numa_node_count() -> u32 {
646 #[cfg(target_os = "windows")]
647 {
648 // SAFETY: GetNumaHighestNodeNumber takes a pointer to a ULONG
649 // and writes the highest node number through it. No allocation.
650 unsafe {
651 let mut highest: u32 = 0;
652 unsafe extern "system" {
653 fn GetNumaHighestNodeNumber(HighestNodeNumber: *mut u32) -> i32;
654 }
655 // Link against kernel32.lib (auto-linked on MSVC targets).
656 let result = GetNumaHighestNodeNumber(&mut highest);
657 if result == 0 {
658 // A zero `BOOL` means failure; fall back to 1 node.
659 1
660 } else {
661 highest.saturating_add(1)
662 }
663 }
664 }
665 #[cfg(not(target_os = "windows"))]
666 {
667 1
668 }
669}
670
671/// Per-NUMA-node sidecar sharding scaffolding. The default global
672/// sidecar handles all instances; multi-sidecar deployment with
673/// per-NUMA pinning would spawn one Sidecar per node and route
674/// registrations by spawn-thread affinity. The detection function
675/// [`numa_node_count`] surfaces the topology; the routing layer plugs
676/// in here when load testing on a multi-socket host motivates it.
677///
678/// Uses `GetCurrentProcessorNumberEx` + `GetNumaProcessorNodeEx` on
679/// Windows: these two work across processor groups (Windows splits
680/// logical processors into groups of up to 64), so the >64-logical-
681/// processor case (dual-socket servers, large core-count workstations)
682/// is handled correctly. `GetNumaProcessorNode`, which is capped at
683/// processor 255, is not used.
684pub fn current_numa_node() -> u32 {
685 #[cfg(target_os = "windows")]
686 {
687 // PROCESSOR_NUMBER per
688 // https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-processor_number
689 // sized layout: Group (`USHORT`) + Number (`BYTE`) + Reserved (`BYTE`) = 4 bytes.
690 #[repr(C)]
691 #[derive(Default, Clone, Copy)]
692 struct ProcessorNumber {
693 group: u16,
694 number: u8,
695 reserved: u8,
696 }
697 // SAFETY: GetCurrentProcessorNumberEx writes through &mut, no allocation.
698 // GetNumaProcessorNodeEx reads from the struct and writes one u16 out.
699 unsafe {
700 unsafe extern "system" {
701 fn GetCurrentProcessorNumberEx(ProcNumber: *mut ProcessorNumber);
702 fn GetNumaProcessorNodeEx(
703 Processor: *const ProcessorNumber,
704 NodeNumber: *mut u16,
705 ) -> i32;
706 }
707 let mut proc = ProcessorNumber::default();
708 GetCurrentProcessorNumberEx(&mut proc);
709 let mut node: u16 = 0;
710 if GetNumaProcessorNodeEx(&proc, &mut node) != 0 {
711 // u16::MAX (0xFFFF) is a documented "no node" sentinel
712 // returned by the API for un-NUMA-classified procs;
713 // route those to node 0.
714 if node == u16::MAX { 0 } else { node as u32 }
715 } else {
716 0
717 }
718 }
719 }
720 #[cfg(not(target_os = "windows"))]
721 {
722 // Linux: read /sys/devices/system/cpu/cpu<id>/topology/physical_package_id
723 // for the current CPU. sched_getcpu() returns the logical CPU index;
724 // /sys exposes the NUMA mapping. Fall back to node 0 if any step
725 // fails (no sysfs, container without /sys, etc).
726 current_numa_node_linux()
727 }
728}
729
730#[cfg(not(target_os = "windows"))]
731fn current_numa_node_linux() -> u32 {
732 use std::fs;
733 // libc::sched_getcpu would be the direct call but we avoid the libc
734 // dep by reading /proc/self/stat field 39 (last_cpu) or by parsing
735 // /sys/.../cpu<N>/topology. For portability across kernels we read
736 // /proc/self/stat which exposes the last-scheduled CPU.
737 let stat = match fs::read_to_string("/proc/self/stat") {
738 Ok(s) => s,
739 Err(_) => return 0,
740 };
741 // /proc/self/stat fields are space-separated past the comm field
742 // (which is parenthesized). Skip past the closing paren.
743 let after_comm = match stat.rfind(')') {
744 Some(i) => &stat[i + 1..],
745 None => return 0,
746 };
747 // Last-scheduled CPU is field 39 in proc(5); we count fields from
748 // after_comm (which is at field 3 boundary because pid, comm are 1-2).
749 let cpu = match after_comm.split_whitespace().nth(36) {
750 Some(s) => match s.parse::<u32>() { Ok(v) => v, Err(_) => return 0 },
751 None => return 0,
752 };
753 let path = format!(
754 "/sys/devices/system/cpu/cpu{cpu}/topology/physical_package_id"
755 );
756 match fs::read_to_string(&path) {
757 Ok(s) => s.trim().parse::<u32>().unwrap_or(0),
758 Err(_) => 0,
759 }
760}
761
762/// RAII handle that auto-unregisters its instance on drop.
763pub struct SidecarHandle {
764 id: InstanceId,
765 sidecar: Arc<Sidecar>,
766}
767
768impl SidecarHandle {
769 pub fn id(&self) -> InstanceId {
770 self.id
771 }
772
773 pub fn stats(&self) -> Option<InstanceStats> {
774 self.sidecar.stats(self.id)
775 }
776}
777
778impl Drop for SidecarHandle {
779 fn drop(&mut self) {
780 self.sidecar.unregister(self.id);
781 }
782}
783
784/// Trait implemented by adaptive primitive instances that opt into
785/// sidecar observation. The Box guarantees stable addresses for the
786/// header and ring.
787pub trait AdaptiveInstance: Send + Sync + 'static {
788 fn header(&self) -> &HandshakeHeader;
789 fn ring(&self) -> &ObservationRing;
790 fn make_policy(&self) -> Box<dyn Policy>;
791
792 /// Called by the sidecar when the policy returns a new strategy
793 /// tag. Default implementation: just set the tag on the header.
794 /// Primitives that need heavier migration (data-layout swap)
795 /// override this to perform the swap before (or after) updating
796 /// the tag.
797 fn apply_migration(&self, new_tag: u32) {
798 self.header().set_tag(new_tag);
799 }
800}
801
802/// Boxed primitive + auto-unregistering sidecar handle.
803///
804/// `Drop` order is well-defined: handle drops first (blocks on scan,
805/// then clears the registry slot), then the box drops (frees the
806/// header/ring memory). No raw-pointer-after-free race.
807pub struct SidecarBox<T: AdaptiveInstance> {
808 // Field order is drop order: handle drops before inner.
809 handle: SidecarHandle,
810 inner: Box<T>,
811}
812
813impl<T: AdaptiveInstance> SidecarBox<T> {
814 pub fn new(value: T) -> Self {
815 let inner = Box::new(value);
816 // SAFETY: Box guarantees stable address until inner is dropped.
817 // Field references and the instance pointer are valid as long
818 // as inner is alive. SidecarHandle::drop runs before inner::drop,
819 // calling unregister(), which blocks until any in-flight scan
820 // finishes.
821 let header = NonNull::from(inner.header());
822 let ring = NonNull::from(inner.ring());
823 let instance_ref: &dyn AdaptiveInstance = &*inner;
824 let instance_ptr: *const dyn AdaptiveInstance = instance_ref;
825 let instance = unsafe {
826 NonNull::new_unchecked(instance_ptr as *mut dyn AdaptiveInstance)
827 };
828 let policy = inner.make_policy();
829 let sidecar = global();
830 let id = unsafe { sidecar.register_raw(header, ring, Some(instance), policy) };
831 Self {
832 handle: SidecarHandle { id, sidecar },
833 inner,
834 }
835 }
836
837 pub fn id(&self) -> InstanceId {
838 self.handle.id
839 }
840
841 pub fn stats(&self) -> Option<InstanceStats> {
842 self.handle.stats()
843 }
844}
845
846impl<T: AdaptiveInstance> std::ops::Deref for SidecarBox<T> {
847 type Target = T;
848 fn deref(&self) -> &T {
849 &self.inner
850 }
851}
852
853#[cfg(test)]
854mod tests {
855 use super::*;
856 use subetha_core::Observation;
857
858 /// A bare instance with just header + ring for sidecar testing.
859 struct BareInstance {
860 header: HandshakeHeader,
861 ring: ObservationRing,
862 }
863
864 impl BareInstance {
865 fn new() -> Self {
866 Self {
867 header: HandshakeHeader::new(),
868 ring: ObservationRing::new(),
869 }
870 }
871 }
872
873 impl AdaptiveInstance for BareInstance {
874 fn header(&self) -> &HandshakeHeader { &self.header }
875 fn ring(&self) -> &ObservationRing { &self.ring }
876 fn make_policy(&self) -> Box<dyn Policy> { Box::new(NoMigrationPolicy) }
877 }
878
879 /// Policy that escalates the tag whenever average latency > threshold.
880 struct EscalatingPolicy {
881 threshold_ticks: u64,
882 escalate_to: u32,
883 }
884
885 impl Policy for EscalatingPolicy {
886 fn decide(&self, stats: &InstanceStats, current_tag: u32) -> Option<u32> {
887 if stats.average_latency_ticks() > self.threshold_ticks && current_tag < self.escalate_to {
888 Some(self.escalate_to)
889 } else {
890 None
891 }
892 }
893 }
894
895 struct EscalatingInstance {
896 header: HandshakeHeader,
897 ring: ObservationRing,
898 }
899
900 impl EscalatingInstance {
901 fn new() -> Self {
902 Self {
903 header: HandshakeHeader::new(),
904 ring: ObservationRing::new(),
905 }
906 }
907 }
908
909 impl AdaptiveInstance for EscalatingInstance {
910 fn header(&self) -> &HandshakeHeader { &self.header }
911 fn ring(&self) -> &ObservationRing { &self.ring }
912 fn make_policy(&self) -> Box<dyn Policy> {
913 Box::new(EscalatingPolicy {
914 threshold_ticks: 500,
915 escalate_to: 2,
916 })
917 }
918 }
919
920 #[test]
921 fn register_unregister_balances() {
922 let s = global();
923 let inst = Box::new(BareInstance::new());
924 let header = NonNull::from(inst.header());
925 let ring = NonNull::from(inst.ring());
926 let id = unsafe {
927 s.register_raw(header, ring, None, Box::new(NoMigrationPolicy))
928 };
929 assert!(s.stats(id).is_some());
930 s.unregister(id);
931 assert!(s.stats(id).is_none());
932 drop(inst);
933 }
934
935 #[test]
936 fn sidecar_drains_observations() {
937 let inst = SidecarBox::new(BareInstance::new());
938
939 // Push 10 observations.
940 for i in 0..10 {
941 assert!(inst.ring.push(Observation {
942 instance_id: 0,
943 op_kind: 1,
944 flags: 0,
945 latency_ticks: 100 + i,
946 ..Observation::ZERO
947 }));
948 }
949
950 // Force a scan.
951 global().scan_now();
952
953 let stats = inst.stats().expect("instance should be registered");
954 assert_eq!(stats.ops_observed, 10);
955 assert!(stats.total_latency_ticks >= 1000);
956 }
957
958 #[test]
959 fn policy_migrates_strategy_when_threshold_crossed() {
960 let inst = SidecarBox::new(EscalatingInstance::new());
961 assert_eq!(inst.header().tag(), 0);
962
963 // Push observations with latency well above threshold (500).
964 for _ in 0..50 {
965 inst.ring.push(Observation {
966 instance_id: 0,
967 op_kind: 1,
968 flags: 0,
969 latency_ticks: 5000,
970 ..Observation::ZERO
971 });
972 }
973
974 global().scan_now();
975
976 // EscalatingPolicy should have set tag to 2.
977 assert_eq!(inst.header().tag(), 2,
978 "policy should have escalated tag to 2 after high-latency observations");
979 }
980
981 #[test]
982 fn unregister_blocks_safe_drop() {
983 // This is the load-bearing race-safety test. We register an
984 // instance, push observations, drop the SidecarBox while the
985 // sidecar may be mid-scan, and rely on the unregister-blocks-on-
986 // scan contract to prevent use-after-free.
987 for _ in 0..50 {
988 let inst = SidecarBox::new(BareInstance::new());
989 // Push observations to make the sidecar dereference our pointers.
990 for _ in 0..100 {
991 inst.ring.push(Observation {
992 instance_id: 0,
993 op_kind: 1,
994 flags: 0,
995 latency_ticks: 10,
996 ..Observation::ZERO
997 });
998 }
999 // Drop while sidecar may be scanning. If unregister doesn't
1000 // block correctly, this leads to use-after-free under TSAN/ASAN.
1001 drop(inst);
1002 }
1003 }
1004
1005 #[test]
1006 fn fixed_policy_sets_tag_immediately() {
1007 struct Inst { h: HandshakeHeader, r: ObservationRing }
1008 impl AdaptiveInstance for Inst {
1009 fn header(&self) -> &HandshakeHeader { &self.h }
1010 fn ring(&self) -> &ObservationRing { &self.r }
1011 fn make_policy(&self) -> Box<dyn Policy> { Box::new(FixedPolicy(7)) }
1012 }
1013 let inst = SidecarBox::new(Inst {
1014 h: HandshakeHeader::new(),
1015 r: ObservationRing::new(),
1016 });
1017
1018 inst.r.push(Observation { instance_id: 0, op_kind: 0, flags: 0, latency_ticks: 1, ..Observation::ZERO });
1019 global().scan_now();
1020 assert_eq!(inst.h.tag(), 7);
1021 }
1022
1023 #[test]
1024 fn instance_count_tracks_register_and_unregister() {
1025 // Use a local Sidecar so this test does not interfere with
1026 // the global one used by other tests.
1027 let s = Sidecar::new();
1028 let start = s.instance_count();
1029
1030 let inst = Box::new(BareInstance::new());
1031 let header = NonNull::from(inst.header());
1032 let ring = NonNull::from(inst.ring());
1033 let id = unsafe {
1034 s.register_raw(header, ring, None, Box::new(NoMigrationPolicy))
1035 };
1036 assert_eq!(s.instance_count(), start + 1);
1037
1038 s.unregister(id);
1039 assert_eq!(s.instance_count(), start);
1040 }
1041
1042 #[test]
1043 fn cap_panic_message_is_actionable() {
1044 // Build a Sidecar with a tiny cap and verify the panic
1045 // message names the actual cap value and mentions the
1046 // diagnostic guidance about loops / b.iter() / set_max_instances.
1047 let s = Sidecar::new();
1048 s.set_max_instances(2);
1049 assert_eq!(s.max_instances(), 2);
1050
1051 // Register up to the cap (no panic).
1052 let inst1 = Box::new(BareInstance::new());
1053 let id1 = unsafe {
1054 s.register_raw(
1055 NonNull::from(inst1.header()),
1056 NonNull::from(inst1.ring()),
1057 None,
1058 Box::new(NoMigrationPolicy),
1059 )
1060 };
1061 let inst2 = Box::new(BareInstance::new());
1062 let id2 = unsafe {
1063 s.register_raw(
1064 NonNull::from(inst2.header()),
1065 NonNull::from(inst2.ring()),
1066 None,
1067 Box::new(NoMigrationPolicy),
1068 )
1069 };
1070
1071 // Third must panic with the documented diagnostic.
1072 let inst3 = Box::new(BareInstance::new());
1073 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1074 unsafe {
1075 s.register_raw(
1076 NonNull::from(inst3.header()),
1077 NonNull::from(inst3.ring()),
1078 None,
1079 Box::new(NoMigrationPolicy),
1080 )
1081 }
1082 }));
1083 let payload = result.expect_err("must panic when over cap");
1084 let msg = payload.downcast_ref::<String>().map(String::as_str)
1085 .or_else(|| payload.downcast_ref::<&'static str>().copied())
1086 .expect("panic payload must be a string");
1087 assert!(msg.contains("instance cap (2) exceeded"),
1088 "panic must name the cap value: {msg}");
1089 assert!(msg.contains("b.iter()") || msg.contains("loop"),
1090 "panic must hint at b.iter() / loop misuse: {msg}");
1091 assert!(msg.contains("set_max_instances"),
1092 "panic must mention the escape hatch: {msg}");
1093
1094 // Failed register must not have incremented the count past cap.
1095 assert_eq!(s.instance_count(), 2,
1096 "count must roll back on cap-rejected register");
1097
1098 // Cleanup so test does not leak.
1099 s.unregister(id1);
1100 s.unregister(id2);
1101 }
1102}