Skip to main content

pictor_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 [`pictor_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: pictor_kernels::KernelTier) -> usize {
262    #[cfg(any(feature = "metal", feature = "native-cuda"))]
263    {
264        if tier == pictor_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 [`pictor_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>, pictor_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 pictor_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 pictor_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 = pictor_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 = pictor_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 = pictor_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, &params)
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, &params)
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/pictor-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, &params)
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(&params);
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, &params)
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                "pictor_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}