Skip to main content

ferrum_models/executor/
llm_executor.rs

1//! `LlmExecutor<M>` — adapts a `DecoderOnlyLLM` to the `ModelExecutor` trait
2//! the engine scheduler calls.
3//!
4//! This is the Model-as-Code equivalent of `GenericModelExecutor`: where
5//! `GenericModelExecutor` wraps a `Box<dyn RunnerInterface>` (legacy
6//! `ModelRunner<B>`), `LlmExecutor` wraps a `Box<dyn DecoderOnlyLLM>`
7//! (new-style per-model code such as `Qwen3Model<B>`).
8//!
9//! Tokens/logits are currently bridged through candle Tensor for
10//! `TensorRef` — Phase C will likely replace that with `SmallTensor` to
11//! drop candle from the hot path.
12
13use std::sync::atomic::{AtomicU64, Ordering};
14use std::sync::{Arc, OnceLock};
15
16use parking_lot::{Mutex, MutexGuard};
17use tracing::debug;
18
19use ferrum_interfaces::{
20    model_executor::{
21        AttentionType, DecodeInput, DecodeOutput, ExecutorCapabilities, ExecutorStatus,
22        KvSlotCapacitySnapshot, KvSlotRequest, KvSlotReservation, LogitsReturnPolicy,
23        MemoryRequirements, PrefillInput, PrefillOutput, UnifiedBatch,
24    },
25    ModelExecutor, RecurrentStateSpec,
26};
27use ferrum_types::{DataType, FerrumError, ModelInfo, RequestId, Result, TokenId};
28
29use crate::common::DecoderOnlyLLM;
30use crate::lora::ActiveLoraAdapter;
31
32use super::common::{self, GenericKvCacheHandle};
33
34const KV_ADMISSION_TARGET_LEN_METADATA_KEY: &str = "ferrum_kv_admission_target_len";
35
36#[derive(Debug, Clone, PartialEq, Eq)]
37struct LlmExecutorRuntimeEnv {
38    batch_prefill_prof: bool,
39    batch_decode_prof: bool,
40}
41
42impl LlmExecutorRuntimeEnv {
43    fn from_env() -> Self {
44        Self::from_runtime_config_snapshot(&ferrum_types::active_runtime_snapshot())
45    }
46
47    fn from_runtime_config_snapshot(snapshot: &ferrum_types::RuntimeConfigSnapshot) -> Self {
48        Self::from_env_vars(
49            snapshot
50                .entries
51                .iter()
52                .map(|entry| (entry.key.as_str(), entry.effective_value.as_str())),
53        )
54    }
55
56    fn from_env_vars<I, K, V>(vars: I) -> Self
57    where
58        I: IntoIterator<Item = (K, V)>,
59        K: AsRef<str>,
60    {
61        let mut batch_prefill_prof = false;
62        let mut batch_decode_prof = false;
63
64        for (key, _) in vars {
65            match key.as_ref() {
66                "FERRUM_BATCH_PREFILL_PROF" => batch_prefill_prof = true,
67                "FERRUM_BATCH_DECODE_PROF" => batch_decode_prof = true,
68                _ => {}
69            }
70        }
71
72        Self {
73            batch_prefill_prof,
74            batch_decode_prof,
75        }
76    }
77}
78
79fn llm_executor_runtime_env() -> &'static LlmExecutorRuntimeEnv {
80    static CONFIG: OnceLock<LlmExecutorRuntimeEnv> = OnceLock::new();
81    CONFIG.get_or_init(LlmExecutorRuntimeEnv::from_env)
82}
83
84fn active_lora_from_metadata(
85    metadata: &std::collections::HashMap<String, serde_json::Value>,
86) -> Result<Option<ActiveLoraAdapter>> {
87    let name = metadata
88        .get("ferrum_lora_adapter")
89        .and_then(|value| value.as_str());
90    let path = metadata
91        .get("ferrum_lora_path")
92        .and_then(|value| value.as_str());
93    match (name, path) {
94        (Some(name), Some(path)) => Ok(Some(ActiveLoraAdapter {
95            name: name.to_string(),
96            path: std::path::PathBuf::from(path),
97        })),
98        (None, None) => Ok(None),
99        _ => Err(FerrumError::model(
100            "incomplete LoRA metadata: expected ferrum_lora_adapter and ferrum_lora_path",
101        )),
102    }
103}
104
105fn metadata_requires_full_logits(
106    metadata: &std::collections::HashMap<String, serde_json::Value>,
107) -> bool {
108    metadata
109        .get("ferrum_require_full_logits")
110        .and_then(|value| value.as_bool())
111        .unwrap_or(false)
112}
113
114fn metadata_kv_capacity_hint(
115    metadata: &std::collections::HashMap<String, serde_json::Value>,
116) -> Option<usize> {
117    metadata
118        .get("ferrum_kv_capacity_hint")
119        .and_then(|value| value.as_u64())
120        .map(|value| value as usize)
121}
122
123fn metadata_kv_admission_target_len(
124    metadata: &std::collections::HashMap<String, serde_json::Value>,
125) -> Option<usize> {
126    metadata
127        .get(KV_ADMISSION_TARGET_LEN_METADATA_KEY)
128        .and_then(|value| value.as_u64())
129        .map(|value| value as usize)
130        .filter(|&value| value > 0)
131}
132
133fn unified_fallback_reason_code(message: &str) -> &'static str {
134    if message.contains("fresh prefill with prefix cache enabled") {
135        "prefix_cache_fresh_prefill"
136    } else if message.contains("backend lacks varlen")
137        || message.contains("varlen QKV support disabled")
138    {
139        "unified_varlen_qkv_disabled"
140    } else if message.contains("sandwich-norm family requires")
141        && message.contains("sliding-window layer pattern")
142    {
143        "sandwich_window_pattern_required"
144    } else if message.contains("sandwich-norm family requires")
145        && message.contains("F32 residual shadow")
146    {
147        "sandwich_f32_shadow_required"
148    } else if message.contains("active LoRA adapter") {
149        "active_lora_adapter"
150    } else if message.contains("paged KV required") {
151        "paged_kv_required"
152    } else {
153        "unified_unsupported"
154    }
155}
156
157fn should_log_unified_decode_prof(call: u64, prefill_items: usize, fallback: bool) -> bool {
158    fallback || prefill_items > 0 || call < 8 || call.is_multiple_of(32)
159}
160
161fn next_unified_decode_prof_call() -> u64 {
162    static CALLS: AtomicU64 = AtomicU64::new(0);
163    CALLS.fetch_add(1, Ordering::Relaxed)
164}
165
166/// Map a `ferrum_types::Device` to the matching `candle_core::Device`.
167/// Used when materialising KV cache handles so downstream readers see
168/// the real backend the model runs on (Metal / CUDA / CPU) rather than
169/// a hard-coded CPU placeholder.
170fn ferrum_device_to_candle(d: &ferrum_types::Device) -> ferrum_types::Result<candle_core::Device> {
171    match d {
172        ferrum_types::Device::CPU => Ok(candle_core::Device::Cpu),
173        #[cfg(feature = "candle-cuda-compat")]
174        ferrum_types::Device::CUDA(i) => candle_core::Device::new_cuda(*i as usize)
175            .map_err(|error| FerrumError::device(format!("CUDA device error: {error}"))),
176        #[cfg(not(feature = "candle-cuda-compat"))]
177        ferrum_types::Device::CUDA(_) => Err(FerrumError::unsupported(
178            "legacy Candle CUDA executor requires the candle-cuda-compat feature",
179        )),
180        #[cfg(all(any(target_os = "macos", target_os = "ios"), feature = "metal"))]
181        ferrum_types::Device::Metal => candle_core::Device::new_metal(0)
182            .map_err(|error| FerrumError::device(format!("Metal device error: {error}"))),
183        #[cfg(all(any(target_os = "macos", target_os = "ios"), not(feature = "metal")))]
184        ferrum_types::Device::Metal => Err(FerrumError::unsupported(
185            "legacy Candle Metal executor requires the metal feature",
186        )),
187        ferrum_types::Device::ROCm(_) => Err(FerrumError::unsupported("ROCm is not supported")),
188    }
189}
190
191pub struct LlmExecutor {
192    model: Mutex<Box<dyn DecoderOnlyLLM>>,
193    info: ModelInfo,
194    vnext_model: Option<Arc<crate::vnext::PreparedProductionModel>>,
195    next_cache_id: AtomicU64,
196    total_model_lock_wait_us: AtomicU64,
197    model_lock_wait_samples: AtomicU64,
198}
199
200impl LlmExecutor {
201    pub fn new(model: Box<dyn DecoderOnlyLLM>, info: ModelInfo) -> Self {
202        Self::new_with_optional_vnext_model(model, info, None)
203    }
204
205    pub fn new_with_vnext_model(
206        model: Box<dyn DecoderOnlyLLM>,
207        info: ModelInfo,
208        vnext_model: Arc<crate::vnext::PreparedProductionModel>,
209    ) -> Self {
210        Self::new_with_optional_vnext_model(model, info, Some(vnext_model))
211    }
212
213    fn new_with_optional_vnext_model(
214        model: Box<dyn DecoderOnlyLLM>,
215        info: ModelInfo,
216        vnext_model: Option<Arc<crate::vnext::PreparedProductionModel>>,
217    ) -> Self {
218        Self {
219            model: Mutex::new(model),
220            info,
221            vnext_model,
222            next_cache_id: AtomicU64::new(0),
223            total_model_lock_wait_us: AtomicU64::new(0),
224            model_lock_wait_samples: AtomicU64::new(0),
225        }
226    }
227
228    pub fn vnext_model(&self) -> Option<&Arc<crate::vnext::PreparedProductionModel>> {
229        self.vnext_model.as_ref()
230    }
231
232    pub fn vnext_family(&self) -> Option<&ferrum_interfaces::vnext::PreparedModelFamily> {
233        self.vnext_model.as_deref().map(|model| model.family())
234    }
235
236    fn lock_model(&self) -> MutexGuard<'_, Box<dyn DecoderOnlyLLM>> {
237        let start = std::time::Instant::now();
238        let guard = self.model.lock();
239        self.record_model_lock_wait(start.elapsed());
240        guard
241    }
242
243    fn record_model_lock_wait(&self, duration: std::time::Duration) {
244        self.total_model_lock_wait_us.fetch_add(
245            duration.as_micros().min(u64::MAX as u128) as u64,
246            Ordering::Relaxed,
247        );
248        self.model_lock_wait_samples.fetch_add(1, Ordering::Relaxed);
249    }
250
251    fn model_lock_metrics_json(&self) -> serde_json::Value {
252        let samples = self.model_lock_wait_samples.load(Ordering::Relaxed);
253        let total_us = self.total_model_lock_wait_us.load(Ordering::Relaxed);
254        serde_json::json!({
255            "schema_version": 1,
256            "samples": samples,
257            "total_wait_time_us": total_us,
258            "avg_wait_time_ms": if samples == 0 {
259                0.0
260            } else {
261                total_us as f64 / samples as f64 / 1000.0
262            },
263        })
264    }
265
266    fn attach_model_lock_metrics(&self, mut snapshot: serde_json::Value) -> serde_json::Value {
267        let lock_metrics = self.model_lock_metrics_json();
268        if let Some(obj) = snapshot.as_object_mut() {
269            obj.insert("executor_model_lock".to_string(), lock_metrics);
270            snapshot
271        } else {
272            serde_json::json!({
273                "cache_metrics": snapshot,
274                "executor_model_lock": lock_metrics,
275            })
276        }
277    }
278
279    fn gen_cache_id(&self) -> String {
280        format!(
281            "llm-cache-{}",
282            self.next_cache_id.fetch_add(1, Ordering::Relaxed)
283        )
284    }
285
286    /// Roll the KV cache for `cache_id` back to `new_len` positions.
287    /// Used by speculative decoding on partial rejection. The caller must
288    /// supply a `GenericKvCacheHandle` whose seq_len is also updated.
289    pub fn truncate_kv_for_cache_id(&self, cache_id: &str, new_len: usize) {
290        let mut model = self.lock_model();
291        model.truncate_kv(cache_id, new_len);
292    }
293}
294
295#[async_trait::async_trait]
296impl ModelExecutor for LlmExecutor {
297    fn info(&self) -> &ModelInfo {
298        &self.info
299    }
300
301    fn supports_native_unified_decode(&self) -> bool {
302        // CUDA has a native unified mixed prefill+decode forward; CPU and Metal
303        // use the legacy split path. The device→capability mapping lives here
304        // (the executor is backend-aware) so the engine needs no platform cfg.
305        matches!(self.info.device, ferrum_types::Device::CUDA(_))
306    }
307
308    fn kv_capacity(&self) -> Option<usize> {
309        Some(self.lock_model().kv_capacity())
310    }
311
312    fn reserve_kv_slots(&self, requests: &[KvSlotRequest]) -> Result<Option<KvSlotReservation>> {
313        self.lock_model().reserve_kv_slots(requests)
314    }
315
316    fn kv_slot_capacity_snapshot(&self) -> Option<KvSlotCapacitySnapshot> {
317        self.lock_model().kv_slot_capacity_snapshot()
318    }
319
320    fn recurrent_state_spec(
321        &self,
322        request_id: &RequestId,
323        input_tokens: &[TokenId],
324    ) -> Result<Option<RecurrentStateSpec>> {
325        let mut spec = self
326            .lock_model()
327            .recurrent_state_spec(request_id, input_tokens)?;
328        if let Some(spec) = spec.as_mut() {
329            spec.device = self.info.device.clone();
330        }
331        Ok(spec)
332    }
333
334    async fn prefill(&self, input: &PrefillInput) -> Result<PrefillOutput> {
335        let tokens = common::tensor_to_tokens(&input.input_ids)?;
336
337        // Reuse an existing cache_id when the caller supplies a KV handle
338        // (chunked prefill) — fresh id only on the very first call for a
339        // request. Without this, every chunk would create a new KV cache
340        // at position 0 and subsequent chunks wouldn't see prior tokens.
341        let supplied_handle_id = input.kv_cache.as_ref().and_then(|h| {
342            h.as_any()
343                .downcast_ref::<GenericKvCacheHandle>()
344                .map(|g| g.request_cache_id().to_string())
345        });
346        let cache_id = supplied_handle_id
347            .clone()
348            .unwrap_or_else(|| self.gen_cache_id());
349
350        // For chunked-prefill continuation, the prior KV length is the seq
351        // length already in the supplied handle; for fresh prefill it's 0.
352        let prior_seq_len = input
353            .kv_cache
354            .as_ref()
355            .and_then(|h| h.as_any().downcast_ref::<GenericKvCacheHandle>())
356            .map(|g| {
357                use ferrum_interfaces::KvCacheHandle;
358                g.block_table().sequence_length
359            })
360            .unwrap_or(0);
361
362        // Try the unified_forward path first when the caller can accept the
363        // model's fast readback path. Requests that need logits processors or
364        // token masks require full logits so the engine sampler can enforce
365        // them; unified_forward currently has no per-item metadata channel.
366        let force_full_logits = metadata_requires_full_logits(&input.metadata);
367        let logits = {
368            let mut model = self.lock_model();
369            model.set_lora_adapter_for_cache(
370                &cache_id,
371                active_lora_from_metadata(&input.metadata)?,
372            )?;
373            if let Some(capacity_hint) = metadata_kv_capacity_hint(&input.metadata) {
374                model.prepare_kv_capacity(&cache_id, capacity_hint);
375            }
376            model.reserve_kv_slots(&[KvSlotRequest {
377                cache_id: cache_id.clone(),
378                target_len: prior_seq_len + tokens.len(),
379                admission_target_len: metadata_kv_admission_target_len(&input.metadata)
380                    .map(|len| len.max(prior_seq_len + tokens.len())),
381            }])?;
382            if force_full_logits {
383                model.prefill(&cache_id, &tokens)
384            } else {
385                let unified_item = vec![(cache_id.clone(), tokens.clone(), prior_seq_len, true)];
386                match model.unified_forward(&unified_item) {
387                    Ok(mut per_item) => per_item
388                        .pop()
389                        .flatten()
390                        .ok_or_else(|| FerrumError::model("unified_forward returned no logits"))?,
391                    Err(FerrumError::Unsupported { .. }) => model.prefill(&cache_id, &tokens),
392                    Err(e) => return Err(e),
393                }
394            }
395        };
396
397        // Wrap logits as TensorRef: [1, 1, vocab_size]
398        let logits_tensor = candle_core::Tensor::new(&logits[..], &candle_core::Device::Cpu)
399            .map_err(|e| FerrumError::model(format!("logits tensor: {e}")))?
400            .unsqueeze(0)
401            .map_err(|e| FerrumError::model(format!("unsqueeze: {e}")))?
402            .unsqueeze(0)
403            .map_err(|e| FerrumError::model(format!("unsqueeze2: {e}")))?;
404        let logits_ref = common::wrap_tensor(logits_tensor);
405
406        let cfg = self.lock_model().config().clone();
407        // Sequence-length tracking across chunks: if the caller supplied a
408        // GenericKvCacheHandle (chunked prefill continuation), add this
409        // chunk's tokens to the prior length. Otherwise this is a fresh
410        // prefill so seq_len == this call's token count. Without this the
411        // handle would claim only the last chunk's length, misleading
412        // decode() into rewriting the KV at an earlier position.
413        let seq_len = input
414            .kv_cache
415            .as_ref()
416            .and_then(|h| h.as_any().downcast_ref::<GenericKvCacheHandle>())
417            .map(|g| {
418                use ferrum_interfaces::KvCacheHandle;
419                g.block_table().sequence_length + tokens.len()
420            })
421            .unwrap_or(tokens.len());
422
423        let kv_handle = Arc::new(GenericKvCacheHandle::new(
424            cfg.num_layers,
425            cfg.num_kv_heads,
426            cfg.head_dim,
427            candle_core::Device::Cpu,
428            seq_len,
429            cache_id,
430        ));
431
432        Ok(PrefillOutput::new(logits_ref, kv_handle))
433    }
434
435    /// Batched prefill: combine all prompts into ONE `model.unified_forward`
436    /// call so launch / kernel-overhead is amortized across the cohort.
437    ///
438    /// Falls back to the trait default (serial per-item) when the model
439    /// returns `Err(unsupported)` from `unified_forward` — e.g. Qwen3MoeModel
440    /// today, until Phase 2 adds its native unified path.
441    async fn batch_prefill(&self, inputs: &[PrefillInput]) -> Result<Vec<PrefillOutput>> {
442        if inputs.is_empty() {
443            return Ok(Vec::new());
444        }
445        let force_full_logits = inputs
446            .iter()
447            .any(|input| metadata_requires_full_logits(&input.metadata));
448
449        // Per-input: derive cache_id (reuse supplied handle's id or generate
450        // fresh) + prior_seq_len. Mirrors the single-prefill path so chunked
451        // prefill continuations route correctly when batched.
452        let mut cache_ids = Vec::with_capacity(inputs.len());
453        let mut prior_seq_lens = Vec::with_capacity(inputs.len());
454        let mut tokens_per_input = Vec::with_capacity(inputs.len());
455        let mut lora_per_input = Vec::with_capacity(inputs.len());
456        for input in inputs {
457            let tokens = common::tensor_to_tokens(&input.input_ids)?;
458            let supplied_handle_id = input.kv_cache.as_ref().and_then(|h| {
459                h.as_any()
460                    .downcast_ref::<GenericKvCacheHandle>()
461                    .map(|g| g.request_cache_id().to_string())
462            });
463            let cache_id = supplied_handle_id
464                .clone()
465                .unwrap_or_else(|| self.gen_cache_id());
466            let prior_seq_len = input
467                .kv_cache
468                .as_ref()
469                .and_then(|h| h.as_any().downcast_ref::<GenericKvCacheHandle>())
470                .map(|g| {
471                    use ferrum_interfaces::KvCacheHandle;
472                    g.block_table().sequence_length
473                })
474                .unwrap_or(0);
475            cache_ids.push(cache_id);
476            prior_seq_lens.push(prior_seq_len);
477            tokens_per_input.push(tokens);
478            lora_per_input.push(active_lora_from_metadata(&input.metadata)?);
479        }
480
481        // Build unified items and ONE `unified_forward` call. If the model
482        // doesn't support it, fall back to the trait-default serial path.
483        let unified_items: Vec<(String, Vec<u32>, usize, bool)> = cache_ids
484            .iter()
485            .zip(tokens_per_input.iter())
486            .zip(prior_seq_lens.iter())
487            .map(|((cid, toks), &prior)| (cid.clone(), toks.clone(), prior, true))
488            .collect();
489
490        let nb_prof = llm_executor_runtime_env().batch_prefill_prof;
491        let bp_t0 = if nb_prof {
492            Some(std::time::Instant::now())
493        } else {
494            None
495        };
496        let mut took_fallback = false;
497        let mut fallback_reason = "none";
498        let per_item_logits: Vec<Vec<f32>> = {
499            let mut model = self.lock_model();
500            for ((cache_id, adapter), input) in cache_ids
501                .iter()
502                .zip(lora_per_input.iter())
503                .zip(inputs.iter())
504            {
505                model.set_lora_adapter_for_cache(cache_id, adapter.clone())?;
506                if let Some(capacity_hint) = metadata_kv_capacity_hint(&input.metadata) {
507                    model.prepare_kv_capacity(cache_id, capacity_hint);
508                }
509            }
510            let kv_requests: Vec<KvSlotRequest> = cache_ids
511                .iter()
512                .zip(prior_seq_lens.iter())
513                .zip(tokens_per_input.iter())
514                .zip(inputs.iter())
515                .map(|(((cache_id, prior_seq_len), tokens), input)| {
516                    let target_len = prior_seq_len.saturating_add(tokens.len());
517                    KvSlotRequest {
518                        cache_id: cache_id.clone(),
519                        target_len,
520                        admission_target_len: metadata_kv_admission_target_len(&input.metadata)
521                            .map(|len| len.max(target_len)),
522                    }
523                })
524                .collect();
525            model.reserve_kv_slots(&kv_requests)?;
526            if force_full_logits {
527                took_fallback = true;
528                fallback_reason = "requires_full_logits";
529                let mut out = Vec::with_capacity(inputs.len());
530                for (cid, toks) in cache_ids.iter().zip(tokens_per_input.iter()) {
531                    out.push(model.prefill(cid, toks));
532                }
533                out
534            } else {
535                match model.unified_forward(&unified_items) {
536                    Ok(per_item) => per_item
537                        .into_iter()
538                        .map(|opt| opt.expect("is_final_chunk=true must yield logits"))
539                        .collect(),
540                    Err(FerrumError::Unsupported { message }) => {
541                        took_fallback = true;
542                        fallback_reason = unified_fallback_reason_code(&message);
543                        let mut out = Vec::with_capacity(inputs.len());
544                        for (cid, toks) in cache_ids.iter().zip(tokens_per_input.iter()) {
545                            out.push(model.prefill(cid, toks));
546                        }
547                        out
548                    }
549                    Err(e) => return Err(e),
550                }
551            }
552        };
553        if let Some(t0) = bp_t0 {
554            let total_q: usize = unified_items.iter().map(|it| it.1.len()).sum();
555            eprintln!(
556                "[batch-prefill] n_items={} total_q={} fallback={} fallback_reason={} elapsed={}us",
557                inputs.len(),
558                total_q,
559                took_fallback,
560                fallback_reason,
561                t0.elapsed().as_micros()
562            );
563        }
564
565        let cfg = self.lock_model().config().clone();
566        let mut outputs = Vec::with_capacity(inputs.len());
567        for (i, logits) in per_item_logits.into_iter().enumerate() {
568            let logits_tensor = candle_core::Tensor::new(&logits[..], &candle_core::Device::Cpu)
569                .map_err(|e| FerrumError::model(format!("logits tensor: {e}")))?
570                .unsqueeze(0)
571                .map_err(|e| FerrumError::model(format!("unsqueeze: {e}")))?
572                .unsqueeze(0)
573                .map_err(|e| FerrumError::model(format!("unsqueeze2: {e}")))?;
574            let logits_ref = common::wrap_tensor(logits_tensor);
575            let seq_len = inputs[i]
576                .kv_cache
577                .as_ref()
578                .and_then(|h| h.as_any().downcast_ref::<GenericKvCacheHandle>())
579                .map(|g| {
580                    use ferrum_interfaces::KvCacheHandle;
581                    g.block_table().sequence_length + tokens_per_input[i].len()
582                })
583                .unwrap_or(tokens_per_input[i].len());
584            let kv_handle = Arc::new(GenericKvCacheHandle::new(
585                cfg.num_layers,
586                cfg.num_kv_heads,
587                cfg.head_dim,
588                candle_core::Device::Cpu,
589                seq_len,
590                cache_ids[i].clone(),
591            ));
592            outputs.push(PrefillOutput::new(logits_ref, kv_handle));
593        }
594        Ok(outputs)
595    }
596
597    async fn truncate_kv(
598        &self,
599        kv_cache: &Arc<dyn ferrum_interfaces::KvCacheHandle>,
600        new_len: usize,
601    ) -> Result<()> {
602        if let Some(g) = kv_cache.as_any().downcast_ref::<GenericKvCacheHandle>() {
603            let cache_id = g.request_cache_id();
604            self.lock_model().truncate_kv(cache_id, new_len);
605        }
606        Ok(())
607    }
608
609    async fn forward_verify(
610        &self,
611        inputs: &[ferrum_interfaces::model_executor::DecodeInput],
612    ) -> Result<Vec<ferrum_interfaces::model_executor::DecodeOutput>> {
613        if inputs.is_empty() {
614            return Ok(Vec::new());
615        }
616
617        // All inputs must share the same KV handle (speculative decoding
618        // contract). Extract cache_id + starting seq_len once.
619        let first_handle = inputs[0].kv_cache.clone();
620        let cache_id = first_handle
621            .as_any()
622            .downcast_ref::<GenericKvCacheHandle>()
623            .ok_or_else(|| {
624                FerrumError::model("forward_verify requires GenericKvCacheHandle input")
625            })?
626            .request_cache_id()
627            .to_string();
628        let start_seq = {
629            use ferrum_interfaces::KvCacheHandle;
630            first_handle.block_table().sequence_length
631        };
632
633        // Collect the N+1 token ids.
634        let mut token_ids: Vec<u32> = Vec::with_capacity(inputs.len());
635        for input in inputs {
636            let toks = common::tensor_to_tokens(&input.input_ids)?;
637            if toks.is_empty() {
638                return Err(FerrumError::model("forward_verify input token empty"));
639            }
640            token_ids.push(toks[0]);
641        }
642
643        // One model forward for all N+1 positions → flat seq_len*vocab.
644        let flat = {
645            let mut model = self.lock_model();
646            model.set_lora_adapter_for_cache(
647                &cache_id,
648                active_lora_from_metadata(&inputs[0].metadata)?,
649            )?;
650            model.forward_verify(&cache_id, &token_ids)
651        };
652
653        let cfg = self.lock_model().config().clone();
654        let vocab = cfg.vocab_size;
655
656        // Record the actual backend device so downstream code that reads
657        // `KvCacheHandle::device()` sees Metal/CUDA/CPU matching the
658        // model's real location. The logits `Tensor` still wraps CPU data
659        // because `B::to_vec` already moved it off-device.
660        let candle_device = ferrum_device_to_candle(&self.info.device)?;
661
662        // Split the flat logits into per-position tensors, each wrapped
663        // with a handle whose seq_len reflects the positions written so
664        // far. Matches what the spec runner expects from sequential
665        // decode() calls.
666        let mut outputs = Vec::with_capacity(inputs.len());
667        for (i, _) in inputs.iter().enumerate() {
668            let row = &flat[i * vocab..(i + 1) * vocab];
669            let logits_tensor = candle_core::Tensor::new(row, &candle_core::Device::Cpu)
670                .map_err(|e| FerrumError::model(format!("logits tensor: {e}")))?
671                .unsqueeze(0)
672                .map_err(|e| FerrumError::model(format!("unsqueeze: {e}")))?;
673            let logits_ref = common::wrap_tensor(logits_tensor);
674            let handle = Arc::new(GenericKvCacheHandle::new(
675                cfg.num_layers,
676                cfg.num_kv_heads,
677                cfg.head_dim,
678                candle_device.clone(),
679                start_seq + i + 1,
680                cache_id.clone(),
681            ));
682            outputs.push(ferrum_interfaces::model_executor::DecodeOutput::new(
683                logits_ref, handle,
684            ));
685        }
686        Ok(outputs)
687    }
688
689    async fn decode(&self, input: &DecodeInput) -> Result<DecodeOutput> {
690        let input_handle = input
691            .kv_cache
692            .as_any()
693            .downcast_ref::<GenericKvCacheHandle>()
694            .ok_or_else(|| FerrumError::model("Invalid KV cache handle type"))?;
695
696        let cache_id = input_handle.request_cache_id().to_string();
697        let seq_len = {
698            use ferrum_interfaces::KvCacheHandle;
699            input_handle.block_table().sequence_length
700        };
701
702        let tokens = common::tensor_to_tokens(&input.input_ids)?;
703        if tokens.is_empty() {
704            return Err(FerrumError::model("Decode input is empty"));
705        }
706        let token = tokens[0];
707
708        debug!("LlmExecutor decode: token={token}, pos={seq_len}");
709
710        // Try unified_forward first unless the engine needs full logits for
711        // masks/processors. The direct decode path returns vocabulary logits.
712        let force_full_logits = metadata_requires_full_logits(&input.metadata);
713        let logits = {
714            let mut model = self.lock_model();
715            model.set_lora_adapter_for_cache(
716                &cache_id,
717                active_lora_from_metadata(&input.metadata)?,
718            )?;
719            model.reserve_kv_slots(&[KvSlotRequest {
720                cache_id: cache_id.clone(),
721                target_len: seq_len.saturating_add(1),
722                admission_target_len: metadata_kv_admission_target_len(&input.metadata)
723                    .map(|len| len.max(seq_len.saturating_add(1))),
724            }])?;
725            if force_full_logits || input.logits_policy.requires_full_logits() {
726                model.decode(&cache_id, token, seq_len as u32)
727            } else {
728                let tuple = [(cache_id.clone(), token, seq_len as u32)];
729                let policy = std::slice::from_ref(&input.logits_policy);
730                model
731                    .decode_batch_with_logits_policy(&tuple, policy)
732                    .pop()
733                    .ok_or_else(|| FerrumError::model("decode_batch returned no logits"))?
734            }
735        };
736
737        let logits_tensor = candle_core::Tensor::new(&logits[..], &candle_core::Device::Cpu)
738            .map_err(|e| FerrumError::model(format!("logits tensor: {e}")))?
739            .unsqueeze(0)
740            .map_err(|e| FerrumError::model(format!("unsqueeze: {e}")))?;
741        let logits_ref = common::wrap_tensor(logits_tensor);
742
743        let kv_handle = Arc::new(input_handle.with_sequence_length(seq_len + 1));
744        Ok(DecodeOutput::new(logits_ref, kv_handle))
745    }
746
747    /// Override default fallback to acquire the model lock ONCE for the whole
748    /// batch, avoiding N round-trips through parking_lot. Does not yet do
749    /// true attention batching (each cache has its own kv_len), but removes
750    /// mutex churn that was serialising concurrent requests at async level.
751    async fn batch_decode(&self, inputs: &[DecodeInput]) -> Result<Vec<DecodeOutput>> {
752        if inputs.is_empty() {
753            return Ok(Vec::new());
754        }
755        let prof = llm_executor_runtime_env().batch_decode_prof;
756        let t0 = if prof {
757            Some(std::time::Instant::now())
758        } else {
759            None
760        };
761        // Pre-extract all per-input metadata OUTSIDE the lock — this is pure
762        // borrow/downcast work that doesn't touch the model.
763        struct Prep {
764            cache_id: String,
765            token: u32,
766            seq_len: u32,
767            lora: Option<ActiveLoraAdapter>,
768            requires_full_logits: bool,
769            logits_policy: LogitsReturnPolicy,
770            handle: Arc<GenericKvCacheHandle>,
771        }
772        let mut prepped: Vec<Prep> = Vec::with_capacity(inputs.len());
773        for input in inputs {
774            let input_handle = input
775                .kv_cache
776                .as_any()
777                .downcast_ref::<GenericKvCacheHandle>()
778                .ok_or_else(|| FerrumError::model("Invalid KV cache handle type"))?;
779            use ferrum_interfaces::KvCacheHandle;
780            let seq_len = input_handle.block_table().sequence_length as u32;
781            let tokens = common::tensor_to_tokens(&input.input_ids)?;
782            if tokens.is_empty() {
783                return Err(FerrumError::model("Decode input is empty"));
784            }
785            prepped.push(Prep {
786                cache_id: input_handle.request_cache_id().to_string(),
787                token: tokens[0],
788                seq_len,
789                lora: active_lora_from_metadata(&input.metadata)?,
790                requires_full_logits: metadata_requires_full_logits(&input.metadata),
791                logits_policy: input.logits_policy.clone(),
792                handle: Arc::new(input_handle.with_sequence_length((seq_len + 1) as usize)),
793            });
794        }
795        let t_prep = if prof {
796            Some(std::time::Instant::now())
797        } else {
798            None
799        };
800
801        // One lock for the whole batch. Try unified_forward first: paged
802        // configs route through the varlen kernel (single mixed dispatch
803        // for the whole batch); contig configs fall back to model's
804        // legacy decode_batch (separate paged_decode_attention call per
805        // item, batched matmul for QKV/MLP).
806        let (all_logits, t_lock_acq, t_model_call): (Vec<Vec<f32>>, _, _) = {
807            let lock_t0 = if prof {
808                Some(std::time::Instant::now())
809            } else {
810                None
811            };
812            let mut model = self.lock_model();
813            let lock_acq = lock_t0.map(|t| t.elapsed());
814            let model_t0 = if prof {
815                Some(std::time::Instant::now())
816            } else {
817                None
818            };
819            for p in &prepped {
820                model.set_lora_adapter_for_cache(&p.cache_id, p.lora.clone())?;
821            }
822            let kv_requests: Vec<KvSlotRequest> = prepped
823                .iter()
824                .map(|p| KvSlotRequest {
825                    cache_id: p.cache_id.clone(),
826                    target_len: (p.seq_len as usize).saturating_add(1),
827                    admission_target_len: None,
828                })
829                .collect();
830            model.reserve_kv_slots(&kv_requests)?;
831            let unified_items: Vec<(String, Vec<u32>, usize, bool)> = prepped
832                .iter()
833                .map(|p| (p.cache_id.clone(), vec![p.token], p.seq_len as usize, true))
834                .collect();
835            let tuples: Vec<(String, u32, u32)> = prepped
836                .iter()
837                .map(|p| (p.cache_id.clone(), p.token, p.seq_len))
838                .collect();
839            let force_full_logits = prepped
840                .iter()
841                .any(|p| p.requires_full_logits || p.logits_policy.requires_full_logits());
842            let logits = if force_full_logits {
843                model.decode_batch_with_full_logits(&tuples, true)
844            } else {
845                let policies: Vec<_> = prepped.iter().map(|p| p.logits_policy.clone()).collect();
846                match model.unified_forward_with_logits_policy(&unified_items, &policies) {
847                    Ok(per_item) => {
848                        if per_item.len() != prepped.len() {
849                            return Err(FerrumError::model(format!(
850                                "unified_forward returned {} entries for {} items",
851                                per_item.len(),
852                                prepped.len(),
853                            )));
854                        }
855                        let mut out = Vec::with_capacity(prepped.len());
856                        for (i, opt) in per_item.into_iter().enumerate() {
857                            out.push(opt.ok_or_else(|| {
858                                FerrumError::model(format!(
859                                    "unified_forward returned None for decode item {i}"
860                                ))
861                            })?);
862                        }
863                        out
864                    }
865                    Err(FerrumError::Unsupported { .. }) => {
866                        model.decode_batch_with_logits_policy(&tuples, &policies)
867                    }
868                    Err(e) => return Err(e),
869                }
870            };
871            let model_call = model_t0.map(|t| t.elapsed());
872            (logits, lock_acq, model_call)
873        };
874        let t_model_done = if prof {
875            Some(std::time::Instant::now())
876        } else {
877            None
878        };
879
880        let m_count = prepped.len();
881        let mut outputs = Vec::with_capacity(m_count);
882        for (p, logits) in prepped.into_iter().zip(all_logits.into_iter()) {
883            debug!(
884                "LlmExecutor batch_decode: token={}, pos={}",
885                p.token, p.seq_len
886            );
887            let logits_tensor = candle_core::Tensor::new(&logits[..], &candle_core::Device::Cpu)
888                .map_err(|e| FerrumError::model(format!("logits tensor: {e}")))?
889                .unsqueeze(0)
890                .map_err(|e| FerrumError::model(format!("unsqueeze: {e}")))?;
891            let logits_ref = common::wrap_tensor(logits_tensor);
892            outputs.push(DecodeOutput::new(logits_ref, p.handle));
893        }
894        if let (Some(t0), Some(tp), Some(tm)) = (t0, t_prep, t_model_done) {
895            static EX_PROF_CALLS: std::sync::atomic::AtomicU64 =
896                std::sync::atomic::AtomicU64::new(0);
897            let n = EX_PROF_CALLS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
898            if n.is_multiple_of(8) {
899                let total = t0.elapsed().as_micros();
900                let prep = tp.duration_since(t0).as_micros();
901                let lock_acq = t_lock_acq.map(|d| d.as_micros()).unwrap_or(0);
902                let model_call = t_model_call.map(|d| d.as_micros()).unwrap_or(0);
903                let model_block = tm.duration_since(tp).as_micros();
904                let wrap = tm.elapsed().as_micros();
905                eprintln!(
906                    "[exec-batch-decode-prof] call#{} m={} total={}us prep={}us model_block={}us(lock_acq={}us model_call={}us) wrap={}us",
907                    n, m_count, total, prep, model_block, lock_acq, model_call, wrap,
908                );
909            }
910        }
911        Ok(outputs)
912    }
913
914    /// Unified mixed-batch dispatch (chunked-prefill API).
915    ///
916    /// This impl is a behavior-preserving FALLBACK over the existing
917    /// trait methods on `DecoderOnlyLLM`: prefill items go through
918    /// `model.prefill(seq_id, &q_tokens)` (one at a time, mirroring the
919    /// engine's current sequential prefill loop), decode items
920    /// (`q_len == 1 && is_final_chunk`) are grouped into a single
921    /// `model.decode_batch(...)` call. Net behavior is identical to the
922    /// engine's pre-Phase-13 path; this just changes WHO orchestrates
923    /// the prefill/decode split (caller → unified_decode) so the engine
924    /// can converge on a single call.
925    ///
926    /// The real performance unlock comes in Step 5 when models override
927    /// this with a true unified-forward (one [M_total, hidden] forward
928    /// + varlen attention) — at that point the kernel-level mix replaces
929    /// the host-side serial dispatch here.
930    async fn unified_decode(&self, batch: &UnifiedBatch) -> Result<Vec<Option<Vec<f32>>>> {
931        let mut results: Vec<Option<Vec<f32>>> = vec![None; batch.items.len()];
932        if batch.items.is_empty() {
933            return Ok(results);
934        }
935        let env = llm_executor_runtime_env();
936        let prof = env.batch_prefill_prof || env.batch_decode_prof;
937        let prof_t0 = prof.then(std::time::Instant::now);
938        let total_q: usize = batch.items.iter().map(|item| item.q_tokens.len()).sum();
939        let profile_decode_items = batch
940            .items
941            .iter()
942            .filter(|item| item.q_tokens.len() == 1 && item.is_final_chunk)
943            .count();
944        let profile_prefill_items = batch.items.len().saturating_sub(profile_decode_items);
945
946        // ── Real unified path (Step 5b+): if the model implements
947        // `DecoderOnlyLLM::unified_forward`, route the entire batch
948        // through one model forward (mixed prefill chunks + decode
949        // tokens in a single [M_total, hidden] pass). The model returns
950        // `Err(unsupported)` if it hasn't been wired yet — fall through
951        // to the behaviour-preserving fallback below.
952        let unified_items: Vec<(String, Vec<u32>, usize, bool)> = batch
953            .items
954            .iter()
955            .map(|it| {
956                (
957                    it.seq_id.clone(),
958                    it.q_tokens.clone(),
959                    it.pos_offset,
960                    it.is_final_chunk,
961                )
962            })
963            .collect();
964        let force_full_logits = batch.items.iter().any(|item| {
965            metadata_requires_full_logits(&item.metadata)
966                || item.logits_policy.requires_full_logits()
967        });
968        let logits_policies: Vec<_> = batch
969            .items
970            .iter()
971            .map(|item| item.logits_policy.clone())
972            .collect();
973        let mut attempted_unified = false;
974        let mut fallback_reason = "none";
975        {
976            let model_result = {
977                let mut model = self.lock_model();
978                for item in &batch.items {
979                    model.set_lora_adapter_for_cache(
980                        &item.seq_id,
981                        active_lora_from_metadata(&item.metadata)?,
982                    )?;
983                    if item.pos_offset == 0 {
984                        if let Some(capacity_hint) = metadata_kv_capacity_hint(&item.metadata) {
985                            model.prepare_kv_capacity(&item.seq_id, capacity_hint);
986                        }
987                    }
988                }
989                let kv_requests: Vec<KvSlotRequest> = batch
990                    .items
991                    .iter()
992                    .map(|item| KvSlotRequest {
993                        cache_id: item.seq_id.clone(),
994                        target_len: item.pos_offset.saturating_add(item.q_tokens.len()),
995                        admission_target_len: metadata_kv_admission_target_len(&item.metadata).map(
996                            |len| len.max(item.pos_offset.saturating_add(item.q_tokens.len())),
997                        ),
998                    })
999                    .collect();
1000                model.reserve_kv_slots(&kv_requests)?;
1001                if force_full_logits && !model.unified_forward_can_return_full_logits() {
1002                    fallback_reason = "requires_full_logits_unavailable";
1003                    None
1004                } else {
1005                    attempted_unified = true;
1006                    Some(model.unified_forward_with_logits_policy(&unified_items, &logits_policies))
1007                }
1008            };
1009            if let Some(model_result) = model_result {
1010                match model_result {
1011                    Ok(per_item) => {
1012                        if per_item.len() != batch.items.len() {
1013                            return Err(FerrumError::model(format!(
1014                                "unified_forward returned {} entries for {} items",
1015                                per_item.len(),
1016                                batch.items.len(),
1017                            )));
1018                        }
1019                        if let Some(t0) = prof_t0 {
1020                            let n = next_unified_decode_prof_call();
1021                            if should_log_unified_decode_prof(n, profile_prefill_items, false) {
1022                                eprintln!(
1023                                    "[unified-decode] call#{} items={} prefill={} decode={} total_q={} attempted_unified={} fallback=false fallback_reason=none elapsed={}us",
1024                                    n,
1025                                    batch.items.len(),
1026                                    profile_prefill_items,
1027                                    profile_decode_items,
1028                                    total_q,
1029                                    attempted_unified,
1030                                    t0.elapsed().as_micros()
1031                                );
1032                            }
1033                        }
1034                        return Ok(per_item);
1035                    }
1036                    Err(FerrumError::Unsupported { message }) => {
1037                        fallback_reason = unified_fallback_reason_code(&message);
1038                        // Fall through to the dispatch fallback below.
1039                    }
1040                    Err(e) => return Err(e),
1041                }
1042            }
1043        }
1044
1045        // Partition: pure decode items vs prefill chunks.
1046        // A "decode" item has q_len == 1 AND is_final_chunk == true.
1047        // Anything else (chunked prefill mid-stream OR a single-token
1048        // prefill that returns logits) goes through the per-item prefill
1049        // path so the model receives the right pos_offset behaviour.
1050        let mut prefill_indices: Vec<usize> = Vec::new();
1051        let mut decode_indices: Vec<usize> = Vec::new();
1052        for (i, item) in batch.items.iter().enumerate() {
1053            if item.q_tokens.len() == 1 && item.is_final_chunk {
1054                decode_indices.push(i);
1055            } else {
1056                prefill_indices.push(i);
1057            }
1058        }
1059
1060        // Prefill items — sequential, mirrors current engine behaviour.
1061        // Held under a single model lock to amortise lock acquire across
1062        // all prefills in this batch (we may revisit per-call locking
1063        // when chunked-prefill becomes the perf-critical path).
1064        if !prefill_indices.is_empty() {
1065            let mut model = self.lock_model();
1066            for &i in &prefill_indices {
1067                let item = &batch.items[i];
1068                model.set_lora_adapter_for_cache(
1069                    &item.seq_id,
1070                    active_lora_from_metadata(&item.metadata)?,
1071                )?;
1072                if item.pos_offset == 0 {
1073                    if let Some(capacity_hint) = metadata_kv_capacity_hint(&item.metadata) {
1074                        model.prepare_kv_capacity(&item.seq_id, capacity_hint);
1075                    }
1076                }
1077                let logits = model.prefill(&item.seq_id, &item.q_tokens);
1078                if item.is_final_chunk {
1079                    results[i] = Some(logits);
1080                }
1081            }
1082        }
1083
1084        // Decode items — single batched dispatch.
1085        if !decode_indices.is_empty() {
1086            let tuples: Vec<(String, u32, u32)> = decode_indices
1087                .iter()
1088                .map(|&i| {
1089                    let it = &batch.items[i];
1090                    (it.seq_id.clone(), it.q_tokens[0], it.pos_offset as u32)
1091                })
1092                .collect();
1093            let logits_vec = {
1094                let mut model = self.lock_model();
1095                for &i in &decode_indices {
1096                    let item = &batch.items[i];
1097                    model.set_lora_adapter_for_cache(
1098                        &item.seq_id,
1099                        active_lora_from_metadata(&item.metadata)?,
1100                    )?;
1101                }
1102                let policies: Vec<_> = decode_indices
1103                    .iter()
1104                    .map(|&i| batch.items[i].logits_policy.clone())
1105                    .collect();
1106                if policies.iter().any(
1107                    ferrum_interfaces::model_executor::LogitsReturnPolicy::requires_full_logits,
1108                ) {
1109                    model.decode_batch_with_full_logits(&tuples, true)
1110                } else {
1111                    model.decode_batch_with_logits_policy(&tuples, &policies)
1112                }
1113            };
1114            for (j, &i) in decode_indices.iter().enumerate() {
1115                results[i] = Some(logits_vec[j].clone());
1116            }
1117        }
1118
1119        if let Some(t0) = prof_t0 {
1120            let n = next_unified_decode_prof_call();
1121            if should_log_unified_decode_prof(n, profile_prefill_items, true) {
1122                eprintln!(
1123                    "[unified-decode] call#{} items={} prefill={} decode={} total_q={} attempted_unified={} fallback=true fallback_reason={} elapsed={}us",
1124                    n,
1125                    batch.items.len(),
1126                    profile_prefill_items,
1127                    profile_decode_items,
1128                    total_q,
1129                    attempted_unified,
1130                    fallback_reason,
1131                    t0.elapsed().as_micros()
1132                );
1133            }
1134        }
1135        Ok(results)
1136    }
1137
1138    fn release_cache(&self, cache_id: &str) {
1139        self.lock_model().release(cache_id);
1140    }
1141
1142    fn capabilities(&self) -> ExecutorCapabilities {
1143        let cfg = self.lock_model().config().clone();
1144        ExecutorCapabilities {
1145            max_batch_size: 256,
1146            max_sequence_length: cfg.max_seq_len,
1147            attention_mechanisms: vec![AttentionType::GroupedQuery],
1148            supports_dynamic_batching: true,
1149            supports_continuous_batching: true,
1150            supports_speculative_decoding: false,
1151            supports_tensor_parallelism: false,
1152            supports_pipeline_parallelism: false,
1153            supported_dtypes: vec![DataType::FP32],
1154            supported_devices: vec![self.info.device.clone()],
1155            memory_requirements: MemoryRequirements {
1156                parameter_memory: (self.info.num_parameters * 4) as u64,
1157                activation_memory_per_token: cfg.hidden_size * 4,
1158                kv_cache_memory_per_token: cfg.hidden_size * 2,
1159                overhead_memory: 256 * 1024 * 1024,
1160            },
1161        }
1162    }
1163
1164    fn status(&self) -> ExecutorStatus {
1165        common::default_executor_status()
1166    }
1167
1168    fn cache_metrics_snapshot(&self) -> Option<serde_json::Value> {
1169        let snapshot = self.lock_model().cache_metrics_snapshot()?;
1170        Some(self.attach_model_lock_metrics(snapshot))
1171    }
1172
1173    fn lora_metrics_snapshot(&self) -> Option<serde_json::Value> {
1174        self.lock_model().lora_metrics_snapshot()
1175    }
1176}
1177
1178#[cfg(test)]
1179mod tests {
1180    use super::*;
1181    use std::collections::HashMap;
1182
1183    use ferrum_interfaces::model_executor::{DecodeInput, PrefillInput, UnifiedBatchItem};
1184    use ferrum_interfaces::KvCacheHandle;
1185    use ferrum_testkit::MockTensor;
1186    use ferrum_types::{Device, ModelId, ModelType};
1187
1188    #[derive(Default)]
1189    struct RecordingCalls {
1190        unified_forward: usize,
1191        unified_forward_items: Vec<Vec<(String, Vec<u32>, usize, bool)>>,
1192        unified_forward_policy_requires_full: Vec<Vec<bool>>,
1193        prefill: usize,
1194        decode: usize,
1195        decode_batch_force_full_logits: Vec<bool>,
1196        prepared_kv: Vec<(String, usize)>,
1197    }
1198
1199    struct RecordingLlm {
1200        calls: Arc<Mutex<RecordingCalls>>,
1201        config: crate::common::LlmRuntimeConfig,
1202        unified_unsupported_message: Option<String>,
1203        unified_full_logits_supported: bool,
1204        recurrent_state_spec: Option<RecurrentStateSpec>,
1205    }
1206
1207    impl RecordingLlm {
1208        fn new(calls: Arc<Mutex<RecordingCalls>>) -> Self {
1209            Self {
1210                calls,
1211                unified_unsupported_message: None,
1212                unified_full_logits_supported: true,
1213                recurrent_state_spec: None,
1214                config: crate::common::LlmRuntimeConfig {
1215                    hidden_size: 4,
1216                    num_layers: 1,
1217                    num_kv_heads: 1,
1218                    head_dim: 4,
1219                    vocab_size: 4,
1220                    max_seq_len: 16,
1221                },
1222            }
1223        }
1224
1225        fn with_unified_unsupported_message(
1226            calls: Arc<Mutex<RecordingCalls>>,
1227            message: impl Into<String>,
1228        ) -> Self {
1229            Self {
1230                unified_unsupported_message: Some(message.into()),
1231                ..Self::new(calls)
1232            }
1233        }
1234
1235        fn with_recurrent_state_spec(
1236            calls: Arc<Mutex<RecordingCalls>>,
1237            spec: RecurrentStateSpec,
1238        ) -> Self {
1239            Self {
1240                recurrent_state_spec: Some(spec),
1241                ..Self::new(calls)
1242            }
1243        }
1244
1245        fn without_unified_full_logits(calls: Arc<Mutex<RecordingCalls>>) -> Self {
1246            Self {
1247                unified_full_logits_supported: false,
1248                ..Self::new(calls)
1249            }
1250        }
1251    }
1252
1253    impl DecoderOnlyLLM for RecordingLlm {
1254        fn config(&self) -> &crate::common::LlmRuntimeConfig {
1255            &self.config
1256        }
1257
1258        fn recurrent_state_spec(
1259            &self,
1260            request_id: &RequestId,
1261            _input_tokens: &[TokenId],
1262        ) -> Result<Option<RecurrentStateSpec>> {
1263            let Some(spec) = &self.recurrent_state_spec else {
1264                return Ok(None);
1265            };
1266            let mut spec = spec.clone();
1267            spec.request_id = request_id.clone();
1268            Ok(Some(spec))
1269        }
1270
1271        fn prefill(&mut self, _cache_id: &str, _tokens: &[u32]) -> Vec<f32> {
1272            self.calls.lock().prefill += 1;
1273            vec![0.0, 1.0, 2.0, 3.0]
1274        }
1275
1276        fn decode(&mut self, _cache_id: &str, _token: u32, _pos: u32) -> Vec<f32> {
1277            self.calls.lock().decode += 1;
1278            vec![3.0, 2.0, 1.0, 0.0]
1279        }
1280
1281        fn decode_batch_with_full_logits(
1282            &mut self,
1283            batch: &[(String, u32, u32)],
1284            force_full_logits: bool,
1285        ) -> Vec<Vec<f32>> {
1286            self.calls
1287                .lock()
1288                .decode_batch_force_full_logits
1289                .push(force_full_logits);
1290            batch.iter().map(|_| vec![3.0, 2.0, 1.0, 0.0]).collect()
1291        }
1292
1293        fn unified_forward(
1294            &mut self,
1295            items: &[(String, Vec<u32>, usize, bool)],
1296        ) -> std::result::Result<Vec<Option<Vec<f32>>>, FerrumError> {
1297            let mut calls = self.calls.lock();
1298            calls.unified_forward += 1;
1299            calls.unified_forward_items.push(items.to_vec());
1300            drop(calls);
1301            if let Some(message) = &self.unified_unsupported_message {
1302                return Err(FerrumError::unsupported(message.clone()));
1303            }
1304            Ok(items
1305                .iter()
1306                .map(|(_, _, _, is_final_chunk)| is_final_chunk.then_some(vec![0.0, 1.0, 2.0, 3.0]))
1307                .collect())
1308        }
1309
1310        fn unified_forward_with_logits_policy(
1311            &mut self,
1312            items: &[(String, Vec<u32>, usize, bool)],
1313            policies: &[ferrum_interfaces::model_executor::LogitsReturnPolicy],
1314        ) -> std::result::Result<Vec<Option<Vec<f32>>>, FerrumError> {
1315            self.calls.lock().unified_forward_policy_requires_full.push(
1316                policies
1317                    .iter()
1318                    .map(
1319                        ferrum_interfaces::model_executor::LogitsReturnPolicy::requires_full_logits,
1320                    )
1321                    .collect(),
1322            );
1323            self.unified_forward(items)
1324        }
1325
1326        fn unified_forward_can_return_full_logits(&self) -> bool {
1327            self.unified_full_logits_supported
1328        }
1329
1330        fn prepare_kv_capacity(&mut self, cache_id: &str, capacity_hint: usize) {
1331            self.calls
1332                .lock()
1333                .prepared_kv
1334                .push((cache_id.to_string(), capacity_hint));
1335        }
1336
1337        fn release(&mut self, _cache_id: &str) {}
1338
1339        fn cache_metrics_snapshot(&self) -> Option<serde_json::Value> {
1340            Some(serde_json::json!({
1341                "position": "recording-test-cache",
1342            }))
1343        }
1344    }
1345
1346    fn test_model_info() -> ModelInfo {
1347        ModelInfo {
1348            model_id: ModelId("recording".to_string()),
1349            model_type: ModelType::Custom("recording".to_string()),
1350            num_parameters: 0,
1351            hidden_size: 4,
1352            num_layers: 1,
1353            num_heads: 1,
1354            num_kv_heads: 1,
1355            vocab_size: 4,
1356            max_sequence_length: 16,
1357            dtype: DataType::FP32,
1358            device: Device::CPU,
1359            version: None,
1360            license: None,
1361            metadata: HashMap::new(),
1362        }
1363    }
1364
1365    fn recording_executor(calls: Arc<Mutex<RecordingCalls>>) -> LlmExecutor {
1366        LlmExecutor::new(Box::new(RecordingLlm::new(calls)), test_model_info())
1367    }
1368
1369    fn recording_executor_with_unified_unsupported(
1370        calls: Arc<Mutex<RecordingCalls>>,
1371        message: impl Into<String>,
1372    ) -> LlmExecutor {
1373        LlmExecutor::new(
1374            Box::new(RecordingLlm::with_unified_unsupported_message(
1375                calls, message,
1376            )),
1377            test_model_info(),
1378        )
1379    }
1380
1381    #[test]
1382    fn llm_executor_delegates_recurrent_state_spec_and_sets_executor_device() {
1383        let calls = Arc::new(Mutex::new(RecordingCalls::default()));
1384        let spec = RecurrentStateSpec {
1385            request_id: RequestId::new(),
1386            num_layers: 1,
1387            tensors: vec![ferrum_interfaces::RecurrentStateTensorSpec::new(
1388                0,
1389                "delta_state",
1390                vec![1, 2, 3],
1391                DataType::FP32,
1392            )],
1393            device: Device::CPU,
1394            max_batch_slots: 1,
1395        };
1396        let mut info = test_model_info();
1397        info.device = Device::CUDA(0);
1398        let executor = LlmExecutor::new(
1399            Box::new(RecordingLlm::with_recurrent_state_spec(calls, spec.clone())),
1400            info,
1401        );
1402        let request_id = RequestId::new();
1403
1404        let actual = executor
1405            .recurrent_state_spec(&request_id, &[TokenId::new(7)])
1406            .expect("recurrent state spec should resolve")
1407            .expect("recording model should expose recurrent state");
1408
1409        assert_eq!(actual.request_id, request_id);
1410        assert_eq!(actual.device, Device::CUDA(0));
1411        assert_eq!(actual.max_batch_slots, spec.max_batch_slots);
1412        assert_eq!(actual.tensors, spec.tensors);
1413    }
1414
1415    fn recording_executor_without_unified_full_logits(
1416        calls: Arc<Mutex<RecordingCalls>>,
1417    ) -> LlmExecutor {
1418        LlmExecutor::new(
1419            Box::new(RecordingLlm::without_unified_full_logits(calls)),
1420            test_model_info(),
1421        )
1422    }
1423
1424    fn full_logits_metadata() -> HashMap<String, serde_json::Value> {
1425        HashMap::from([(
1426            "ferrum_require_full_logits".to_string(),
1427            serde_json::json!(true),
1428        )])
1429    }
1430
1431    fn kv_capacity_hint_metadata(capacity_hint: usize) -> HashMap<String, serde_json::Value> {
1432        HashMap::from([(
1433            "ferrum_kv_capacity_hint".to_string(),
1434            serde_json::json!(capacity_hint),
1435        )])
1436    }
1437
1438    fn test_kv_handle(cache_id: &str, seq_len: usize) -> Arc<dyn KvCacheHandle> {
1439        Arc::new(GenericKvCacheHandle::new(
1440            1,
1441            1,
1442            4,
1443            candle_core::Device::Cpu,
1444            seq_len,
1445            cache_id.to_string(),
1446        ))
1447    }
1448
1449    #[test]
1450    fn llm_executor_runtime_env_parses_profile_flags_by_presence() {
1451        let env = LlmExecutorRuntimeEnv::from_env_vars([
1452            ("FERRUM_BATCH_PREFILL_PROF", ""),
1453            ("FERRUM_BATCH_DECODE_PROF", "0"),
1454        ]);
1455
1456        assert!(env.batch_prefill_prof);
1457        assert!(env.batch_decode_prof);
1458    }
1459
1460    #[test]
1461    fn llm_executor_runtime_env_parses_runtime_snapshot_profile_flags() {
1462        let snapshot = ferrum_types::RuntimeConfigSnapshot::from_entries([
1463            ferrum_types::RuntimeConfigEntry::new(
1464                "FERRUM_BATCH_PREFILL_PROF",
1465                "1",
1466                ferrum_types::RuntimeConfigSource::ConfigFile,
1467            ),
1468            ferrum_types::RuntimeConfigEntry::new(
1469                "FERRUM_BATCH_DECODE_PROF",
1470                "1",
1471                ferrum_types::RuntimeConfigSource::ConfigFile,
1472            ),
1473        ]);
1474        let env = LlmExecutorRuntimeEnv::from_runtime_config_snapshot(&snapshot);
1475
1476        assert!(env.batch_prefill_prof);
1477        assert!(env.batch_decode_prof);
1478    }
1479
1480    #[test]
1481    fn llm_executor_runtime_env_defaults_profile_flags_off() {
1482        let env = LlmExecutorRuntimeEnv::from_env_vars([("UNRELATED", "1")]);
1483
1484        assert!(!env.batch_prefill_prof);
1485        assert!(!env.batch_decode_prof);
1486    }
1487
1488    #[test]
1489    fn unified_fallback_reason_code_classifies_gemma3_varlen_guard() {
1490        assert_eq!(
1491            unified_fallback_reason_code(
1492                "LlamaFamilyModel::unified_forward: varlen QKV support disabled. \
1493                 Engine will fall back to per-item dispatch."
1494            ),
1495            "unified_varlen_qkv_disabled"
1496        );
1497        assert_eq!(
1498            unified_fallback_reason_code(
1499                "LlamaFamilyModel::unified_forward: sandwich-norm family requires \
1500                 backend device-side F32 residual shadow support for unified GeGLU"
1501            ),
1502            "sandwich_f32_shadow_required"
1503        );
1504        assert_eq!(
1505            unified_fallback_reason_code("unrecognized model-specific reason"),
1506            "unified_unsupported"
1507        );
1508    }
1509
1510    #[test]
1511    fn unified_decode_prof_logs_prefill_fallback_and_sampled_decode() {
1512        assert!(should_log_unified_decode_prof(100, 1, false));
1513        assert!(should_log_unified_decode_prof(100, 0, true));
1514        assert!(should_log_unified_decode_prof(0, 0, false));
1515        assert!(should_log_unified_decode_prof(32, 0, false));
1516        assert!(!should_log_unified_decode_prof(31, 0, false));
1517    }
1518
1519    #[test]
1520    fn batch_prefill_falls_back_after_unified_unsupported() {
1521        let calls = Arc::new(Mutex::new(RecordingCalls::default()));
1522        let executor = recording_executor_with_unified_unsupported(
1523            calls.clone(),
1524            "LlamaFamilyModel::unified_forward: varlen QKV support disabled. \
1525             Engine will fall back to per-item dispatch.",
1526        );
1527        let inputs = vec![
1528            PrefillInput::new(MockTensor::from_u32(&[1, 2], &[2]).into_ref()),
1529            PrefillInput::new(MockTensor::from_u32(&[3, 4, 5], &[3]).into_ref()),
1530        ];
1531
1532        let outputs = tokio_test::block_on(executor.batch_prefill(&inputs)).unwrap();
1533
1534        assert_eq!(outputs.len(), 2);
1535        let calls = calls.lock();
1536        assert_eq!(calls.unified_forward, 1);
1537        assert_eq!(calls.prefill, 2);
1538    }
1539
1540    #[test]
1541    fn prefill_skips_unified_forward_when_full_logits_required() {
1542        let calls = Arc::new(Mutex::new(RecordingCalls::default()));
1543        let executor = recording_executor(calls.clone());
1544        let input = PrefillInput::new(MockTensor::from_u32(&[1, 2], &[2]).into_ref())
1545            .with_metadata(full_logits_metadata());
1546
1547        let output = tokio_test::block_on(executor.prefill(&input)).unwrap();
1548
1549        assert_eq!(
1550            output
1551                .last_token_logits()
1552                .unwrap()
1553                .to_vec_f32()
1554                .unwrap()
1555                .len(),
1556            4
1557        );
1558        let calls = calls.lock();
1559        assert_eq!(calls.unified_forward, 0);
1560        assert_eq!(calls.prefill, 1);
1561    }
1562
1563    #[test]
1564    fn decode_skips_unified_forward_when_full_logits_required() {
1565        let calls = Arc::new(Mutex::new(RecordingCalls::default()));
1566        let executor = recording_executor(calls.clone());
1567        let input = DecodeInput::new(
1568            MockTensor::from_u32(&[7], &[1]).into_ref(),
1569            test_kv_handle("decode-cache", 3),
1570        )
1571        .with_metadata(full_logits_metadata());
1572
1573        let output = tokio_test::block_on(executor.decode(&input)).unwrap();
1574
1575        assert_eq!(output.logits.to_vec_f32().unwrap().len(), 4);
1576        let calls = calls.lock();
1577        assert_eq!(calls.unified_forward, 0);
1578        assert_eq!(calls.decode, 1);
1579    }
1580
1581    #[test]
1582    fn unified_decode_uses_unified_forward_when_full_logits_supported() {
1583        let calls = Arc::new(Mutex::new(RecordingCalls::default()));
1584        let executor = recording_executor(calls.clone());
1585        let mut batch = UnifiedBatch::new();
1586        batch.items.push(UnifiedBatchItem {
1587            seq_id: "decode-cache".to_string(),
1588            q_tokens: vec![7],
1589            kv_cache: test_kv_handle("decode-cache", 3),
1590            recurrent_state: None,
1591            pos_offset: 3,
1592            is_final_chunk: true,
1593            metadata: full_logits_metadata(),
1594            logits_policy: Default::default(),
1595        });
1596
1597        let output = tokio_test::block_on(executor.unified_decode(&batch)).unwrap();
1598
1599        assert_eq!(output[0].as_ref().unwrap().len(), 4);
1600        let calls = calls.lock();
1601        assert_eq!(calls.unified_forward, 1);
1602        assert!(calls.decode_batch_force_full_logits.is_empty());
1603    }
1604
1605    #[test]
1606    fn unified_decode_forwards_logits_policy_to_unified_model() {
1607        let calls = Arc::new(Mutex::new(RecordingCalls::default()));
1608        let executor = recording_executor(calls.clone());
1609        let mut batch = UnifiedBatch::new();
1610        batch.items.push(UnifiedBatchItem {
1611            seq_id: "decode-cache".to_string(),
1612            q_tokens: vec![7],
1613            kv_cache: test_kv_handle("decode-cache", 3),
1614            recurrent_state: None,
1615            pos_offset: 3,
1616            is_final_chunk: true,
1617            metadata: HashMap::new(),
1618            logits_policy: ferrum_interfaces::model_executor::LogitsReturnPolicy::GreedyArgmax {
1619                token_mask: None,
1620                repetition_penalty: None,
1621            },
1622        });
1623
1624        let output = tokio_test::block_on(executor.unified_decode(&batch)).unwrap();
1625
1626        assert_eq!(output[0].as_ref().unwrap().len(), 4);
1627        let calls = calls.lock();
1628        assert_eq!(calls.unified_forward, 1);
1629        assert_eq!(
1630            calls.unified_forward_policy_requires_full,
1631            vec![vec![false]]
1632        );
1633        assert!(calls.decode_batch_force_full_logits.is_empty());
1634    }
1635
1636    #[test]
1637    fn unified_decode_forwards_prefill_logits_policy_to_unified_model() {
1638        let calls = Arc::new(Mutex::new(RecordingCalls::default()));
1639        let executor = recording_executor(calls.clone());
1640        let mut batch = UnifiedBatch::new();
1641        batch.items.push(UnifiedBatchItem {
1642            seq_id: "prefill-cache".to_string(),
1643            q_tokens: vec![1, 2, 3],
1644            kv_cache: test_kv_handle("prefill-cache", 0),
1645            recurrent_state: None,
1646            pos_offset: 0,
1647            is_final_chunk: true,
1648            metadata: HashMap::new(),
1649            logits_policy: ferrum_interfaces::model_executor::LogitsReturnPolicy::GreedyArgmax {
1650                token_mask: None,
1651                repetition_penalty: None,
1652            },
1653        });
1654
1655        let output = tokio_test::block_on(executor.unified_decode(&batch)).unwrap();
1656
1657        assert_eq!(output[0].as_ref().unwrap().len(), 4);
1658        let calls = calls.lock();
1659        assert_eq!(calls.unified_forward, 1);
1660        assert_eq!(
1661            calls.unified_forward_policy_requires_full,
1662            vec![vec![false]]
1663        );
1664        assert!(calls.decode_batch_force_full_logits.is_empty());
1665    }
1666
1667    #[test]
1668    fn unified_decode_forwards_mixed_fresh_prefill_and_decode_to_unified_model() {
1669        let calls = Arc::new(Mutex::new(RecordingCalls::default()));
1670        let executor = recording_executor(calls.clone());
1671        let greedy = ferrum_interfaces::model_executor::LogitsReturnPolicy::GreedyArgmax {
1672            token_mask: None,
1673            repetition_penalty: None,
1674        };
1675        let mut batch = UnifiedBatch::new();
1676        batch.items.push(UnifiedBatchItem {
1677            seq_id: "fresh-cache".to_string(),
1678            q_tokens: vec![1],
1679            kv_cache: test_kv_handle("fresh-cache", 0),
1680            recurrent_state: None,
1681            pos_offset: 0,
1682            is_final_chunk: false,
1683            metadata: HashMap::new(),
1684            logits_policy: greedy.clone(),
1685        });
1686        batch.items.push(UnifiedBatchItem {
1687            seq_id: "decode-cache".to_string(),
1688            q_tokens: vec![7],
1689            kv_cache: test_kv_handle("decode-cache", 3),
1690            recurrent_state: None,
1691            pos_offset: 3,
1692            is_final_chunk: true,
1693            metadata: HashMap::new(),
1694            logits_policy: greedy,
1695        });
1696
1697        let output = tokio_test::block_on(executor.unified_decode(&batch)).unwrap();
1698
1699        assert!(output[0].is_none());
1700        assert_eq!(output[1].as_ref().unwrap().len(), 4);
1701        let calls = calls.lock();
1702        assert_eq!(calls.unified_forward, 1);
1703        assert_eq!(calls.prefill, 0);
1704        assert_eq!(calls.decode, 0);
1705        assert!(calls.decode_batch_force_full_logits.is_empty());
1706        assert_eq!(
1707            calls.unified_forward_items,
1708            vec![vec![
1709                ("fresh-cache".to_string(), vec![1], 0, false),
1710                ("decode-cache".to_string(), vec![7], 3, true),
1711            ]]
1712        );
1713        assert_eq!(
1714            calls.unified_forward_policy_requires_full,
1715            vec![vec![false, false]]
1716        );
1717    }
1718
1719    #[test]
1720    fn batch_decode_forwards_logits_policy_to_unified_model() {
1721        let calls = Arc::new(Mutex::new(RecordingCalls::default()));
1722        let executor = recording_executor(calls.clone());
1723        let inputs = vec![DecodeInput::new(
1724            MockTensor::from_u32(&[7], &[1]).into_ref(),
1725            test_kv_handle("decode-cache", 3),
1726        )
1727        .with_logits_policy(
1728            ferrum_interfaces::model_executor::LogitsReturnPolicy::GreedyArgmax {
1729                token_mask: None,
1730                repetition_penalty: None,
1731            },
1732        )];
1733
1734        let output = tokio_test::block_on(executor.batch_decode(&inputs)).unwrap();
1735
1736        assert_eq!(output[0].logits.to_vec_f32().unwrap().len(), 4);
1737        let calls = calls.lock();
1738        assert_eq!(calls.unified_forward, 1);
1739        assert_eq!(
1740            calls.unified_forward_policy_requires_full,
1741            vec![vec![false]]
1742        );
1743        assert!(calls.decode_batch_force_full_logits.is_empty());
1744    }
1745
1746    #[test]
1747    fn unified_decode_skips_unified_forward_when_full_logits_unsupported() {
1748        let calls = Arc::new(Mutex::new(RecordingCalls::default()));
1749        let executor = recording_executor_without_unified_full_logits(calls.clone());
1750        let mut batch = UnifiedBatch::new();
1751        batch.items.push(UnifiedBatchItem {
1752            seq_id: "decode-cache".to_string(),
1753            q_tokens: vec![7],
1754            kv_cache: test_kv_handle("decode-cache", 3),
1755            recurrent_state: None,
1756            pos_offset: 3,
1757            is_final_chunk: true,
1758            metadata: full_logits_metadata(),
1759            logits_policy: Default::default(),
1760        });
1761
1762        let output = tokio_test::block_on(executor.unified_decode(&batch)).unwrap();
1763
1764        assert_eq!(output[0].as_ref().unwrap().len(), 4);
1765        let calls = calls.lock();
1766        assert_eq!(calls.unified_forward, 0);
1767        assert_eq!(calls.decode_batch_force_full_logits, vec![true]);
1768    }
1769
1770    #[test]
1771    fn unified_decode_prepares_fresh_prefill_kv_capacity_hint() {
1772        let calls = Arc::new(Mutex::new(RecordingCalls::default()));
1773        let executor = recording_executor(calls.clone());
1774        let mut batch = UnifiedBatch::new();
1775        batch.items.push(UnifiedBatchItem {
1776            seq_id: "prefill-cache".to_string(),
1777            q_tokens: vec![1, 2, 3],
1778            kv_cache: test_kv_handle("prefill-cache", 0),
1779            recurrent_state: None,
1780            pos_offset: 0,
1781            is_final_chunk: true,
1782            metadata: kv_capacity_hint_metadata(7),
1783            logits_policy: Default::default(),
1784        });
1785        batch.items.push(UnifiedBatchItem {
1786            seq_id: "decode-cache".to_string(),
1787            q_tokens: vec![7],
1788            kv_cache: test_kv_handle("decode-cache", 3),
1789            recurrent_state: None,
1790            pos_offset: 3,
1791            is_final_chunk: true,
1792            metadata: kv_capacity_hint_metadata(9),
1793            logits_policy: Default::default(),
1794        });
1795
1796        let output = tokio_test::block_on(executor.unified_decode(&batch)).unwrap();
1797
1798        assert_eq!(output.len(), 2);
1799        let calls = calls.lock();
1800        assert_eq!(calls.unified_forward, 1);
1801        assert_eq!(calls.prepared_kv, vec![("prefill-cache".to_string(), 7)]);
1802    }
1803
1804    #[test]
1805    fn unified_decode_full_logits_prefill_uses_unified_forward_and_prepares_kv_capacity_hint() {
1806        let calls = Arc::new(Mutex::new(RecordingCalls::default()));
1807        let executor = recording_executor(calls.clone());
1808        let mut metadata = full_logits_metadata();
1809        metadata.insert("ferrum_kv_capacity_hint".to_string(), serde_json::json!(11));
1810        let mut batch = UnifiedBatch::new();
1811        batch.items.push(UnifiedBatchItem {
1812            seq_id: "prefill-cache".to_string(),
1813            q_tokens: vec![1, 2, 3],
1814            kv_cache: test_kv_handle("prefill-cache", 0),
1815            recurrent_state: None,
1816            pos_offset: 0,
1817            is_final_chunk: true,
1818            metadata,
1819            logits_policy: Default::default(),
1820        });
1821
1822        let output = tokio_test::block_on(executor.unified_decode(&batch)).unwrap();
1823
1824        assert_eq!(output[0].as_ref().unwrap().len(), 4);
1825        let calls = calls.lock();
1826        assert_eq!(calls.unified_forward, 1);
1827        assert_eq!(calls.prefill, 0);
1828        assert_eq!(calls.prepared_kv, vec![("prefill-cache".to_string(), 11)]);
1829    }
1830
1831    #[test]
1832    fn cache_metrics_snapshot_includes_model_lock_wait_metrics() {
1833        let calls = Arc::new(Mutex::new(RecordingCalls::default()));
1834        let executor = recording_executor(calls);
1835
1836        assert_eq!(executor.kv_capacity(), Some(16));
1837        let metrics = executor.cache_metrics_snapshot().unwrap();
1838
1839        assert_eq!(metrics["position"], "recording-test-cache");
1840        assert_eq!(metrics["executor_model_lock"]["schema_version"], 1);
1841        assert!(
1842            metrics["executor_model_lock"]["samples"].as_u64().unwrap() >= 2,
1843            "metrics: {metrics}"
1844        );
1845        assert!(
1846            metrics["executor_model_lock"]["total_wait_time_us"]
1847                .as_u64()
1848                .is_some(),
1849            "metrics: {metrics}"
1850        );
1851        assert!(
1852            metrics["executor_model_lock"]["avg_wait_time_ms"].is_number(),
1853            "metrics: {metrics}"
1854        );
1855    }
1856}