oxibonsai_runtime/engine_pool.rs
1//! Engine-replica pool for concurrent serving.
2//!
3//! The HTTP server historically wrapped a single [`InferenceEngine`] in a
4//! `tokio::sync::Mutex`, which serialized every request: a long generation
5//! blocked all others for its full duration. This module replaces that single
6//! mutex with a *pool* of `N` engine replicas guarded by a semaphore, so up to
7//! `N` requests can generate concurrently.
8//!
9//! ## Why a pool works (and is safe)
10//!
11//! - Weights are process-global and immutable: [`InferenceEngine::from_gguf_path`]
12//! leaks the mmap and parsed `GgufFile` to `'static`, so every replica borrows
13//! the *same* `&'static GgufFile` zero-copy. The immutable, dequantized
14//! `token_embd` table is likewise shared across replicas via one `Arc<[f32]>`
15//! (see [`build_pool_from_gguf`]). Only the per-replica `KvCache` and light
16//! wrappers are duplicated.
17//! - On CPU tiers (Reference / AVX / NEON), `BonsaiModel::forward` mutates only
18//! `self.kv_cache` over a shared `&dyn OneBitKernel` on immutable weights, so
19//! distinct engine instances run fully parallel.
20//! - On the GPU tier (Metal / CUDA), decode funnels through a process-global
21//! singleton graph owning one KV cache and shared scratch. `N > 1` GPU
22//! replicas give *no* compute parallelism and would corrupt each other's KV,
23//! so the pool size is clamped to `1` on the GPU tier (see
24//! [`resolve_pool_size`]).
25//!
26//! ## Back-compatibility
27//!
28//! The default path wraps exactly one engine in a 1-element pool whose
29//! [`EngineLease`] calls the identical `generate*` methods on the identical
30//! engine. Single-request behavior — including RNG progression — is therefore
31//! byte-identical to the previous single-mutex design.
32//!
33//! ## Lock discipline
34//!
35//! `idle` is a *synchronous* [`std::sync::Mutex`] held only for the duration of
36//! a `pop`/`push` — never across an `.await`. Async waiting happens purely on
37//! the [`tokio::sync::Semaphore`], whose permit count equals the pool size.
38
39use std::mem::ManuallyDrop;
40use std::ops::{Deref, DerefMut};
41use std::sync::{Arc, Mutex};
42
43use tokio::sync::{OwnedSemaphorePermit, Semaphore};
44
45use crate::engine::InferenceEngine;
46
47/// Errors that can occur while acquiring an engine from the pool.
48///
49/// These map to HTTP `503 Service Unavailable` at the call site — they all
50/// represent a transient inability to serve, never a client error.
51#[derive(Debug, thiserror::Error)]
52pub enum PoolError {
53 /// The pool's semaphore was closed (the pool is shutting down).
54 #[error("engine pool is closed")]
55 Closed,
56 /// A permit was acquired but no idle engine was available.
57 ///
58 /// This cannot happen under correct operation — permits and idle engines
59 /// are kept in lock-step by construction — but is surfaced as an error
60 /// rather than a panic to uphold the crate's no-panic policy.
61 #[error("engine pool is unexpectedly empty")]
62 Empty,
63 /// The `idle` mutex was poisoned by a panic in another thread.
64 #[error("engine pool mutex was poisoned")]
65 Poisoned,
66}
67
68/// A pool of [`InferenceEngine`] replicas guarded by a semaphore.
69///
70/// Construct with [`EnginePool::new`]; acquire an engine with
71/// [`EnginePool::acquire`]. The returned [`EngineLease`] derefs to the engine
72/// and returns it to the pool on drop.
73pub struct EnginePool {
74 /// Idle (available) engines. Guarded by a *synchronous* mutex held only for
75 /// `pop`/`push`. The number of engines ever simultaneously checked out plus
76 /// the length of this vector always equals [`EnginePool::size`].
77 idle: Mutex<Vec<InferenceEngine<'static>>>,
78 /// Async gate: exactly `size` permits. A permit is held for the lifetime of
79 /// each outstanding [`EngineLease`] and released only after the engine has
80 /// been returned to `idle`.
81 sem: Arc<Semaphore>,
82 /// Number of replicas in the pool (immutable after construction).
83 size: usize,
84}
85
86impl EnginePool {
87 /// Build a pool from a vector of engine replicas.
88 ///
89 /// The pool size is `engines.len()`, guaranteed to be at least `1` (an
90 /// empty input yields a 1-permit pool with no engines, which would only
91 /// ever return [`PoolError::Empty`]; callers must pass at least one
92 /// engine). The semaphore is seeded with `size` permits.
93 pub fn new(engines: Vec<InferenceEngine<'static>>) -> Arc<Self> {
94 let size = engines.len().max(1);
95 Arc::new(Self {
96 idle: Mutex::new(engines),
97 sem: Arc::new(Semaphore::new(size)),
98 size,
99 })
100 }
101
102 /// Number of replicas in the pool.
103 pub fn size(&self) -> usize {
104 self.size
105 }
106
107 /// Attach a shared [`crate::metrics::InferenceMetrics`] to every replica in the pool.
108 ///
109 /// This wires the per-engine telemetry (prefill / decode-token /
110 /// tokens-per-second histograms recorded inside `generate*`) onto each
111 /// replica, mirroring the single-engine `engine.set_metrics(..)` call the
112 /// CLI `serve` handler performed before this pool existed. It must be called
113 /// while the pool is *idle* (right after construction, before any lease is
114 /// handed out), so all replicas are present in `idle`.
115 ///
116 /// Returns [`PoolError::Poisoned`] if the idle mutex was poisoned. The
117 /// metrics `Arc` is cloned once per replica so they all share one instance.
118 pub fn set_metrics_all(
119 &self,
120 metrics: &Arc<crate::metrics::InferenceMetrics>,
121 ) -> Result<(), PoolError> {
122 let mut idle = self.idle.lock().map_err(|_| PoolError::Poisoned)?;
123 for engine in idle.iter_mut() {
124 engine.set_metrics(Arc::clone(metrics));
125 }
126 Ok(())
127 }
128
129 /// Acquire an engine from the pool, waiting asynchronously if all replicas
130 /// are currently in use.
131 ///
132 /// Returns an [`EngineLease`] that derefs to the engine and returns it to
133 /// the pool on drop. The acquired permit is held for the lease's lifetime.
134 pub async fn acquire(self: &Arc<Self>) -> Result<EngineLease, PoolError> {
135 // Wait for a free slot. The permit count mirrors the idle count, so a
136 // granted permit guarantees an idle engine is (about to be) available.
137 let permit = Arc::clone(&self.sem)
138 .acquire_owned()
139 .await
140 .map_err(|_| PoolError::Closed)?;
141
142 // Pop an idle engine. The lock is held only for this `pop`.
143 let engine = {
144 let mut idle = self.idle.lock().map_err(|_| PoolError::Poisoned)?;
145 idle.pop().ok_or(PoolError::Empty)?
146 };
147
148 Ok(EngineLease {
149 engine: ManuallyDrop::new(engine),
150 pool: Arc::clone(self),
151 _permit: permit,
152 })
153 }
154}
155
156impl std::fmt::Debug for EnginePool {
157 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158 let idle_len = self.idle.lock().map(|g| g.len()).ok();
159 f.debug_struct("EnginePool")
160 .field("size", &self.size)
161 .field("available_permits", &self.sem.available_permits())
162 .field("idle_len", &idle_len)
163 .finish()
164 }
165}
166
167/// An exclusive lease on one engine from an [`EnginePool`].
168///
169/// Derefs (mutably) to the borrowed [`InferenceEngine`], so callers invoke the
170/// usual `generate*` methods directly. On drop, the engine is returned to the
171/// pool and the semaphore permit is released — in that order, so a waiter is
172/// guaranteed to find an idle engine the instant its permit is granted.
173///
174/// The engine is stored in [`ManuallyDrop`] so [`Drop`] can move it back into
175/// the pool without an `Option`/`unwrap` dance: this is the whole reason the
176/// guard is panic-free.
177pub struct EngineLease {
178 engine: ManuallyDrop<InferenceEngine<'static>>,
179 pool: Arc<EnginePool>,
180 // Field order matters: `_permit` is declared last so it is dropped *after*
181 // the explicit `Drop::drop` body below has returned the engine to `idle`.
182 // (Rust drops a struct's fields in declaration order after running its
183 // `Drop::drop`.) This guarantees the slot is only freed once the engine is
184 // back in the pool.
185 _permit: OwnedSemaphorePermit,
186}
187
188impl Deref for EngineLease {
189 type Target = InferenceEngine<'static>;
190
191 fn deref(&self) -> &Self::Target {
192 // Total: `engine` is always populated until `Drop` takes it exactly
193 // once, and the lease is never accessed after drop.
194 &self.engine
195 }
196}
197
198impl DerefMut for EngineLease {
199 fn deref_mut(&mut self) -> &mut Self::Target {
200 // Total: see `deref`.
201 &mut self.engine
202 }
203}
204
205impl Drop for EngineLease {
206 fn drop(&mut self) {
207 // SAFETY: `ManuallyDrop::take` is called exactly once, here in `Drop`;
208 // `self.engine` is never accessed afterwards (the struct is being
209 // destroyed), so no double-take or use-after-take can occur.
210 let engine = unsafe { ManuallyDrop::take(&mut self.engine) };
211
212 // Return the engine to the pool. We deliberately do NOT run a heavy
213 // reset here: every `generate*` entry point resets the model KV cache
214 // at the start of the call (today's behavior), and the sampler's RNG
215 // state is intentionally preserved, so resetting here would be both
216 // redundant and a risk to byte-identical single-request behavior.
217 match self.pool.idle.lock() {
218 Ok(mut idle) => idle.push(engine),
219 Err(_poisoned) => {
220 // The pool mutex is poisoned (a thread panicked while holding
221 // it). Dropping `engine` here is the safe choice: we must not
222 // panic in `Drop`, and pushing into a poisoned pool is not
223 // meaningfully recoverable. The permit still releases below.
224 tracing::error!("engine pool mutex poisoned on lease return; dropping replica");
225 drop(engine);
226 }
227 }
228
229 // `_permit` is dropped *after* this body returns (it is the last field
230 // in declaration order), so the semaphore slot frees only once the
231 // engine is back in `idle`.
232 }
233}
234
235/// Default pool size on CPU tiers: the host's available parallelism, capped at
236/// `4`, with a floor of `1`.
237///
238/// The cap keeps memory bounded (each replica adds its own KV cache; the
239/// `token_embd` table is shared across replicas via one `Arc<[f32]>`) while
240/// still allowing a handful of concurrent generations on typical multi-core
241/// hosts.
242pub fn default_cpu_pool_size() -> usize {
243 std::thread::available_parallelism()
244 .map(|n| n.get().min(4))
245 .unwrap_or(1)
246}
247
248/// Resolve the effective pool size for a given kernel tier.
249///
250/// - On the GPU tier (Metal / CUDA), the size is forced to `1`: GPU decode
251/// funnels through a process-global singleton that cannot run replicas in
252/// parallel and would corrupt shared KV state. This is a correctness
253/// requirement, not a tuning choice.
254/// - On CPU tiers, the size is `requested` (clamped to `>= 1`) or, if `None`,
255/// [`default_cpu_pool_size`].
256///
257/// Pure and unit-testable. The GPU comparison is gated behind the GPU-enabling
258/// features (`metal` / `native-cuda`) because [`oxibonsai_kernels::KernelTier::Gpu`]
259/// only exists when one of them is compiled in; non-GPU builds always take the
260/// CPU branch.
261pub fn resolve_pool_size(requested: Option<usize>, tier: oxibonsai_kernels::KernelTier) -> usize {
262 #[cfg(any(feature = "metal", feature = "native-cuda"))]
263 {
264 if tier == oxibonsai_kernels::KernelTier::Gpu {
265 return 1;
266 }
267 }
268 // Silence the unused-variable lint on non-GPU builds where `tier` is not
269 // inspected.
270 let _ = tier;
271 requested.unwrap_or_else(default_cpu_pool_size).max(1)
272}
273
274/// Build an [`EnginePool`] from a GGUF file, sizing it for the detected tier.
275///
276/// Replica `#1` is loaded via [`InferenceEngine::from_gguf_path`], which
277/// memory-maps and parses the GGUF and leaks both to `'static`. Its kernel tier
278/// is read to size the pool via [`resolve_pool_size`]; replicas `2..size` are
279/// then built off the *same* leaked `&'static GgufFile` via
280/// [`InferenceEngine::from_gguf_static_with_embd`], so no additional mmap or
281/// weight copy occurs. Every replica is seeded identically, so the served
282/// output is deterministic regardless of which replica handles a request.
283///
284/// ## Shared token embedding
285///
286/// The dequantized `token_embd` table (FP32 `vocab × hidden` — ~1.16 GiB for
287/// the 1.7B) is immutable and load-once. Replica `#1` loads it into an
288/// `Arc<[f32]>`; that single `Arc` is then *cloned* (a refcount bump, not a
289/// data copy) into every replica `2..size`. The whole pool therefore holds
290/// **one** embedding allocation regardless of `size`, rather than N duplicates,
291/// and replicas `2..size` skip re-dequantizing it. Per-replica `KvCache`s stay
292/// fully independent.
293///
294/// Returns the pool, the detected [`oxibonsai_kernels::KernelTier`], and the
295/// effective size.
296pub fn build_pool_from_gguf(
297 path: impl AsRef<std::path::Path>,
298 sampling_params: crate::sampling::SamplingParams,
299 seed: u64,
300 max_seq_len: usize,
301 requested_size: Option<usize>,
302) -> crate::error::RuntimeResult<(Arc<EnginePool>, oxibonsai_kernels::KernelTier, usize)> {
303 // Replica #1 — this leaks the mmap + parsed GGUF to `'static`.
304 let (first, gguf) =
305 InferenceEngine::from_gguf_path_leaked(path, sampling_params.clone(), seed, max_seq_len)?;
306
307 let tier = first.kernel_tier();
308 let size = resolve_pool_size(requested_size, tier);
309
310 if requested_size.map(|r| r > size).unwrap_or(false) {
311 tracing::info!(
312 requested = requested_size.unwrap_or(0),
313 effective = size,
314 tier = %tier,
315 "engine pool size clamped to 1 on the GPU tier (process-global GPU singleton)"
316 );
317 } else {
318 tracing::info!(size, tier = %tier, "engine pool built");
319 }
320
321 // Extract replica #1's shared token-embedding table (a cheap refcount-bumped
322 // `Arc<[f32]>` handle, not a copy). Replicas 2..size clone this same `Arc`
323 // instead of re-dequantizing their own ~1.16 GiB copy, so the whole pool
324 // holds one embedding allocation total.
325 let shared_token_embd = first.model_token_embd();
326
327 let mut engines = Vec::with_capacity(size);
328 engines.push(first);
329 // Replicas 2..size reuse the already-`'static` GGUF (zero extra mmap/copy)
330 // and the shared `Arc<[f32]>` token-embedding table (zero extra dequant/copy).
331 for _ in 1..size {
332 let replica = InferenceEngine::from_gguf_static_with_embd(
333 gguf,
334 sampling_params.clone(),
335 seed,
336 max_seq_len,
337 Arc::clone(&shared_token_embd),
338 )?;
339 engines.push(replica);
340 }
341
342 Ok((EnginePool::new(engines), tier, size))
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348 use crate::sampling::SamplingParams;
349 use oxibonsai_core::config::Qwen3Config;
350 use std::time::Duration;
351
352 fn tiny_engine() -> InferenceEngine<'static> {
353 InferenceEngine::new(Qwen3Config::tiny_test(), SamplingParams::default(), 42)
354 }
355
356 // ── synthetic GGUF fixture (for the shared-embd pool test) ───────────────
357 //
358 // A minimal 2-layer, fully-quantized GGUF (h=128, inter=256, vocab=32) that
359 // `BonsaiModel::from_gguf` can load on any CPU tier. Attention/FFN are
360 // Q1_0_g128 and the LM head is Q1_0_g128; the token embedding is F32. This
361 // is the same shape family used by the model crate's ternary integration
362 // fixture, reproduced compactly here so the runtime pool builder can be
363 // exercised end-to-end (it needs a real on-disk GGUF path).
364
365 fn q1_0_g128_data(num_weights: usize) -> Vec<u8> {
366 let num_blocks = num_weights / 128;
367 let scale = half::f16::ONE.to_le_bytes();
368 let mut data = Vec::with_capacity(num_blocks * 18);
369 for _ in 0..num_blocks {
370 data.extend_from_slice(&scale);
371 data.extend_from_slice(&[0xFFu8; 16]);
372 }
373 data
374 }
375
376 fn build_tiny_gguf_bytes() -> Vec<u8> {
377 use oxibonsai_core::gguf::writer::{
378 GgufWriter, MetadataWriteValue, TensorEntry, TensorType,
379 };
380
381 let h: usize = 128;
382 let inter: usize = 256;
383 let num_layers: usize = 2;
384 let nq: usize = 4;
385 let nkv: usize = 2;
386 let hd: usize = 32;
387 let vocab: usize = 32;
388
389 let mut w = GgufWriter::new();
390 w.add_metadata(
391 "general.architecture",
392 MetadataWriteValue::Str("qwen3".into()),
393 );
394 w.add_metadata("general.name", MetadataWriteValue::Str("TinyPool".into()));
395 w.add_metadata("qwen3.embedding_length", MetadataWriteValue::U32(h as u32));
396 w.add_metadata(
397 "qwen3.block_count",
398 MetadataWriteValue::U32(num_layers as u32),
399 );
400 w.add_metadata(
401 "qwen3.attention.head_count",
402 MetadataWriteValue::U32(nq as u32),
403 );
404 w.add_metadata(
405 "qwen3.attention.head_count_kv",
406 MetadataWriteValue::U32(nkv as u32),
407 );
408 w.add_metadata(
409 "qwen3.feed_forward_length",
410 MetadataWriteValue::U32(inter as u32),
411 );
412 w.add_metadata("qwen3.vocab_size", MetadataWriteValue::U32(vocab as u32));
413 w.add_metadata("qwen3.context_length", MetadataWriteValue::U32(512));
414 w.add_metadata(
415 "qwen3.attention.layer_norm_rms_epsilon",
416 MetadataWriteValue::F32(1e-6),
417 );
418 w.add_metadata("qwen3.rope.freq_base", MetadataWriteValue::F32(10_000.0));
419
420 let f32_ones = |n: usize| -> Vec<u8> {
421 let mut v = Vec::with_capacity(n * 4);
422 for _ in 0..n {
423 v.extend_from_slice(&1.0_f32.to_le_bytes());
424 }
425 v
426 };
427
428 w.add_tensor(TensorEntry {
429 name: "token_embd.weight".into(),
430 shape: vec![h as u64, vocab as u64],
431 tensor_type: TensorType::F32,
432 data: f32_ones(vocab * h),
433 });
434 w.add_tensor(TensorEntry {
435 name: "output_norm.weight".into(),
436 shape: vec![h as u64],
437 tensor_type: TensorType::F32,
438 data: f32_ones(h),
439 });
440 w.add_tensor(TensorEntry {
441 name: "output.weight".into(),
442 shape: vec![h as u64, vocab as u64],
443 tensor_type: TensorType::Q1_0G128,
444 data: q1_0_g128_data(vocab * h),
445 });
446
447 for layer in 0..num_layers {
448 let pfx = format!("blk.{layer}");
449 for suffix in ["attn_norm.weight", "ffn_norm.weight"] {
450 w.add_tensor(TensorEntry {
451 name: format!("{pfx}.{suffix}"),
452 shape: vec![h as u64],
453 tensor_type: TensorType::F32,
454 data: f32_ones(h),
455 });
456 }
457 for suffix in ["attn_q_norm.weight", "attn_k_norm.weight"] {
458 w.add_tensor(TensorEntry {
459 name: format!("{pfx}.{suffix}"),
460 shape: vec![hd as u64],
461 tensor_type: TensorType::F32,
462 data: f32_ones(hd),
463 });
464 }
465 let q1 = |name: &str, shape: Vec<u64>, n: usize| TensorEntry {
466 name: name.to_string(),
467 shape,
468 tensor_type: TensorType::Q1_0G128,
469 data: q1_0_g128_data(n),
470 };
471 w.add_tensor(q1(
472 &format!("{pfx}.attn_q.weight"),
473 vec![h as u64, (nq * hd) as u64],
474 nq * hd * h,
475 ));
476 w.add_tensor(q1(
477 &format!("{pfx}.attn_k.weight"),
478 vec![h as u64, (nkv * hd) as u64],
479 nkv * hd * h,
480 ));
481 w.add_tensor(q1(
482 &format!("{pfx}.attn_v.weight"),
483 vec![h as u64, (nkv * hd) as u64],
484 nkv * hd * h,
485 ));
486 w.add_tensor(q1(
487 &format!("{pfx}.attn_output.weight"),
488 vec![(nq * hd) as u64, h as u64],
489 h * nq * hd,
490 ));
491 w.add_tensor(q1(
492 &format!("{pfx}.ffn_gate.weight"),
493 vec![h as u64, inter as u64],
494 inter * h,
495 ));
496 w.add_tensor(q1(
497 &format!("{pfx}.ffn_up.weight"),
498 vec![h as u64, inter as u64],
499 inter * h,
500 ));
501 w.add_tensor(q1(
502 &format!("{pfx}.ffn_down.weight"),
503 vec![inter as u64, h as u64],
504 h * inter,
505 ));
506 }
507
508 w.to_bytes().expect("GgufWriter::to_bytes")
509 }
510
511 // ── resolve_pool_size ────────────────────────────────────────────────
512
513 #[test]
514 fn resolve_pool_size_explicit_cpu() {
515 // On a CPU tier, an explicit request is honored (clamped to >= 1).
516 let tier = oxibonsai_kernels::KernelTier::Reference;
517 assert_eq!(resolve_pool_size(Some(8), tier), 8);
518 assert_eq!(resolve_pool_size(Some(1), tier), 1);
519 // Zero is clamped up to the floor of 1.
520 assert_eq!(resolve_pool_size(Some(0), tier), 1);
521 }
522
523 #[test]
524 fn resolve_pool_size_default_cpu() {
525 let tier = oxibonsai_kernels::KernelTier::Reference;
526 assert_eq!(resolve_pool_size(None, tier), default_cpu_pool_size());
527 }
528
529 #[test]
530 fn default_cpu_pool_size_in_range() {
531 let n = default_cpu_pool_size();
532 assert!((1..=4).contains(&n), "expected 1..=4, got {n}");
533 }
534
535 #[cfg(any(feature = "metal", feature = "native-cuda"))]
536 #[test]
537 fn resolve_pool_size_gpu_is_clamped_to_one() {
538 // The GPU clamp is a correctness property: a process-global singleton
539 // cannot run replicas in parallel.
540 let tier = oxibonsai_kernels::KernelTier::Gpu;
541 assert_eq!(resolve_pool_size(Some(8), tier), 1);
542 assert_eq!(resolve_pool_size(None, tier), 1);
543 assert_eq!(resolve_pool_size(Some(1), tier), 1);
544 }
545
546 // ── lease / pool mechanics ───────────────────────────────────────────
547
548 #[tokio::test]
549 async fn pool_size_reflects_input() {
550 let pool = EnginePool::new(vec![tiny_engine(), tiny_engine()]);
551 assert_eq!(pool.size(), 2);
552 }
553
554 #[tokio::test]
555 async fn acquire_blocks_when_exhausted_then_resumes_on_drop() {
556 let pool = EnginePool::new(vec![tiny_engine(), tiny_engine()]);
557
558 // Take both engines.
559 let lease_a = pool.acquire().await.expect("acquire a");
560 let lease_b = pool.acquire().await.expect("acquire b");
561
562 // Idle is now empty and no permits remain.
563 assert_eq!(pool.sem.available_permits(), 0);
564 {
565 let idle = pool.idle.lock().expect("lock idle");
566 assert!(idle.is_empty(), "idle should be empty with 2/2 checked out");
567 }
568
569 // A third acquire must NOT resolve while both leases are held.
570 let pending = pool.acquire();
571 let timed_out = tokio::time::timeout(Duration::from_millis(150), pending).await;
572 assert!(
573 timed_out.is_err(),
574 "third acquire resolved while pool was exhausted"
575 );
576
577 // Returning one engine must let a waiting acquire proceed.
578 drop(lease_a);
579 let lease_c = tokio::time::timeout(Duration::from_millis(500), pool.acquire())
580 .await
581 .expect("acquire should resolve after a lease is dropped")
582 .expect("acquire c");
583
584 // Drop the rest; the pool returns to full availability.
585 drop(lease_b);
586 drop(lease_c);
587 assert_eq!(pool.sem.available_permits(), 2);
588 {
589 let idle = pool.idle.lock().expect("lock idle");
590 assert_eq!(idle.len(), 2, "all engines should be back in the pool");
591 }
592 }
593
594 // ── single-request golden: byte-identical behavior ───────────────────
595
596 #[tokio::test]
597 async fn single_element_pool_is_byte_identical_to_direct_engine() {
598 // The hard invariant: a 1-element pool that acquires and calls
599 // `generate_with_params` must produce the EXACT same token vector as a
600 // fresh engine with the same config/seed/params calling the same
601 // method directly.
602 let config = Qwen3Config::tiny_test();
603 let params = SamplingParams::default();
604 let seed = 42u64;
605 let prompt: Vec<u32> = vec![151644, 872, 9707, 11];
606 let max_tokens = 8usize;
607
608 // Direct engine baseline.
609 let mut direct = InferenceEngine::new(config.clone(), params.clone(), seed);
610 let direct_out = direct
611 .generate_with_params(&prompt, max_tokens, ¶ms)
612 .expect("direct generate");
613
614 // 1-element pool.
615 let pool = EnginePool::new(vec![InferenceEngine::new(
616 config.clone(),
617 params.clone(),
618 seed,
619 )]);
620 let mut lease = pool.acquire().await.expect("acquire");
621 let pool_out = lease
622 .generate_with_params(&prompt, max_tokens, ¶ms)
623 .expect("pool generate");
624
625 assert_eq!(
626 direct_out, pool_out,
627 "1-element pool output diverged from direct engine — byte-identity broken"
628 );
629 }
630
631 // ── concurrent isolation: no KV / RNG cross-talk ─────────────────────
632
633 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
634 async fn concurrent_leases_match_isolated_baselines() {
635 // NOTE on test strength: `Qwen3Config::tiny_test()` builds a model with
636 // zero-initialized weights and no transformer blocks, so its forward
637 // pass is prompt-INDEPENDENT (every prompt yields the same logits ->
638 // same greedy token). That makes "distinct prompts => distinct outputs"
639 // impossible to assert here. What we CAN — and do — assert is the
640 // structural isolation guarantee: each concurrently-leased engine
641 // produces output bit-identical to the SAME prompt run alone on a fresh
642 // single engine. If concurrent leases shared/corrupted KV or RNG state,
643 // the concurrent outputs would diverge from their isolated baselines.
644 //
645 // A richer cross-talk test (distinct prompts => distinct outputs)
646 // requires non-degenerate weights; that variant is best built behind
647 // `#[cfg(all(feature = "metal", target_os = "macos"))]` using the
648 // synthetic ternary GGUF fixture from
649 // `crates/oxibonsai-model/tests/metal_prefill_ternary_parity_tests.rs`
650 // with a couple of `KernelTier::Reference` replicas. See task notes.
651 use std::sync::Arc as StdArc;
652
653 let config = Qwen3Config::tiny_test();
654 // GREEDY params (temperature 0) make outputs deterministic and remove
655 // any dependence on RNG ordering, isolating the KV-cache question.
656 let params = SamplingParams {
657 temperature: 0.0,
658 ..SamplingParams::default()
659 };
660 let seed = 42u64;
661 let max_tokens = 6usize;
662
663 let prompts: Vec<Vec<u32>> = vec![
664 vec![151644, 872],
665 vec![151644, 9707, 11, 1879],
666 vec![151644, 1986, 374, 264, 1273],
667 vec![151644, 264],
668 ];
669
670 // Isolated baselines: each prompt alone on a fresh single engine.
671 let mut baselines = Vec::with_capacity(prompts.len());
672 for p in &prompts {
673 let mut e = InferenceEngine::new(config.clone(), params.clone(), seed);
674 let out = e
675 .generate_with_params(p, max_tokens, ¶ms)
676 .expect("baseline generate");
677 baselines.push(out);
678 }
679
680 // Pool with one replica per prompt so all run truly concurrently.
681 let engines: Vec<InferenceEngine<'static>> = (0..prompts.len())
682 .map(|_| InferenceEngine::new(config.clone(), params.clone(), seed))
683 .collect();
684 let pool = EnginePool::new(engines);
685
686 let params = StdArc::new(params);
687 let mut handles = Vec::with_capacity(prompts.len());
688 for p in prompts.clone() {
689 let pool = StdArc::clone(&pool);
690 let params = StdArc::clone(¶ms);
691 handles.push(tokio::spawn(async move {
692 let mut lease = pool.acquire().await.expect("acquire");
693 lease
694 .generate_with_params(&p, max_tokens, ¶ms)
695 .expect("concurrent generate")
696 }));
697 }
698
699 for (i, h) in handles.into_iter().enumerate() {
700 let got = h.await.expect("task join");
701 assert_eq!(
702 got, baselines[i],
703 "concurrent task {i} diverged from its isolated baseline — KV/RNG cross-talk"
704 );
705 }
706 }
707
708 // ── shared token-embedding Arc across pool replicas ──────────────────────
709
710 #[tokio::test]
711 async fn pool_replicas_share_one_token_embd_allocation() {
712 // The end-to-end Part-B proof: `build_pool_from_gguf` must build all
713 // replicas sharing ONE `Arc<[f32]>` token-embedding table (collapsing N
714 // duplicate ~1.16 GiB allocations into one for the real 1.7B), while
715 // each replica keeps its own KV cache.
716 //
717 // The synthetic fixture loads on any CPU tier; `from_gguf` auto-detects
718 // the kernel. On a GPU tier the pool clamps to size 1 (a process-global
719 // singleton), in which case the multi-replica ptr-equality assertion is
720 // vacuous — so we skip it and only sanity-check the single replica. On
721 // this Mac `auto_detect` returns NEON (a CPU tier), so the multi-replica
722 // path is the one normally exercised here.
723 let bytes = build_tiny_gguf_bytes();
724 let path = {
725 let mut p = std::env::temp_dir();
726 p.push(format!(
727 "oxibonsai_pool_shared_embd_{}.gguf",
728 std::process::id()
729 ));
730 p
731 };
732 std::fs::write(&path, &bytes).expect("write temp GGUF");
733
734 let (pool, _tier, size) =
735 build_pool_from_gguf(&path, SamplingParams::default(), 42, 512, Some(3))
736 .expect("build_pool_from_gguf");
737
738 // Clean up the temp file now that the GGUF is mmapped + leaked into the
739 // pool (the leaked mmap keeps the bytes alive regardless of the file).
740 let _ = std::fs::remove_file(&path);
741
742 if size <= 1 {
743 // GPU tier (or single-core host): only one replica exists, so there
744 // is nothing to share. Just confirm the lone replica is usable.
745 let lease = pool.acquire().await.expect("acquire sole replica");
746 let embd = lease.model_token_embd();
747 assert!(!embd.is_empty(), "token_embd must be populated");
748 return;
749 }
750
751 // Acquire ALL replicas at once so we can compare every replica's
752 // `token_embd` handle simultaneously. With `size` permits this never
753 // blocks.
754 let mut leases = Vec::with_capacity(size);
755 for _ in 0..size {
756 leases.push(pool.acquire().await.expect("acquire replica"));
757 }
758
759 // Every replica's token_embd must be the SAME allocation.
760 let first_embd = leases[0].model_token_embd();
761 for (i, lease) in leases.iter().enumerate().skip(1) {
762 let other = lease.model_token_embd();
763 assert!(
764 Arc::ptr_eq(&first_embd, &other),
765 "replica #{i} token_embd is a different allocation — sharing broken"
766 );
767 }
768
769 // KV caches must be DISTINCT per replica (per-request mutable state).
770 let kv_ptrs: Vec<*const _> = leases
771 .iter()
772 .map(|l| l.model().kv_cache() as *const _)
773 .collect();
774 for i in 0..kv_ptrs.len() {
775 for j in (i + 1)..kv_ptrs.len() {
776 assert_ne!(
777 kv_ptrs[i], kv_ptrs[j],
778 "replicas #{i} and #{j} share a KV cache — isolation broken"
779 );
780 }
781 }
782
783 // Strong count: all `size` replicas alias the one allocation. We hold
784 // `first_embd` plus `size` replica-held clones; the per-replica handle
785 // pulled inside the loop above has been dropped. So the count is
786 // `size + 1`.
787 assert_eq!(
788 Arc::strong_count(&first_embd),
789 size + 1,
790 "expected {size} replicas + the local handle to alias one allocation"
791 );
792 }
793}