1use 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
166fn 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let candle_device = ferrum_device_to_candle(&self.info.device)?;
661
662 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 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 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 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 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 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 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 }
1040 Err(e) => return Err(e),
1041 }
1042 }
1043 }
1044
1045 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 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 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}