1use crate::registry::{ComponentConfig, ComponentRegistry};
12use ferrum_interfaces::engine::{InferenceEngine, LlmInferenceEngine};
13use ferrum_interfaces::{
14 KvCacheManager, ModelExecutor, RecurrentStateManager, Sampler, SchedulerInterface as Scheduler,
15 TensorFactory, Tokenizer,
16};
17use ferrum_models::vnext::{DefinedProductionModel, ProductionModelSourceBundle};
18use ferrum_types::{EngineConfig, FerrumError, Result};
19use std::sync::Arc;
20use tracing::{debug, info};
21
22pub struct EngineBuilder {
30 registry: Arc<ComponentRegistry>,
32 config: EngineConfig,
34 model_sources: Option<Arc<ProductionModelSourceBundle>>,
36 defined_model: Option<Arc<DefinedProductionModel>>,
39 tokenizer_name: Option<String>,
41 sampler_name: Option<String>,
43 scheduler_name: Option<String>,
45 kv_cache_name: Option<String>,
47 executor_name: Option<String>,
49 custom_tokenizer: Option<Arc<dyn Tokenizer + Send + Sync>>,
51 custom_sampler: Option<Arc<dyn Sampler + Send + Sync>>,
53 custom_scheduler: Option<Arc<dyn Scheduler + Send + Sync>>,
55 custom_kv_cache: Option<Arc<dyn KvCacheManager + Send + Sync>>,
57 custom_recurrent_state_manager: Option<Arc<dyn RecurrentStateManager + Send + Sync>>,
59 custom_executor: Option<Arc<dyn ModelExecutor + Send + Sync>>,
61}
62
63impl EngineBuilder {
64 pub fn new(config: EngineConfig) -> Self {
66 Self::with_registry(config, crate::registry::global_registry())
67 }
68
69 pub fn with_registry(config: EngineConfig, registry: Arc<ComponentRegistry>) -> Self {
71 Self {
72 registry,
73 config,
74 model_sources: None,
75 defined_model: None,
76 tokenizer_name: None,
77 sampler_name: None,
78 scheduler_name: None,
79 kv_cache_name: None,
80 executor_name: None,
81 custom_tokenizer: None,
82 custom_sampler: None,
83 custom_scheduler: None,
84 custom_kv_cache: None,
85 custom_recurrent_state_manager: None,
86 custom_executor: None,
87 }
88 }
89
90 pub fn with_model_sources(mut self, sources: Arc<ProductionModelSourceBundle>) -> Self {
91 self.model_sources = Some(sources);
92 self.defined_model = None;
93 self
94 }
95
96 pub fn with_defined_model(mut self, prepared: Arc<DefinedProductionModel>) -> Self {
97 self.model_sources = Some(Arc::clone(prepared.sources()));
98 self.defined_model = Some(prepared);
99 self
100 }
101
102 pub fn with_tokenizer(mut self, name: impl Into<String>) -> Self {
104 self.tokenizer_name = Some(name.into());
105 self
106 }
107
108 pub fn with_custom_tokenizer(mut self, tokenizer: Arc<dyn Tokenizer + Send + Sync>) -> Self {
110 self.custom_tokenizer = Some(tokenizer);
111 self
112 }
113
114 pub fn with_sampler(mut self, name: impl Into<String>) -> Self {
116 self.sampler_name = Some(name.into());
117 self
118 }
119
120 pub fn with_custom_sampler(mut self, sampler: Arc<dyn Sampler + Send + Sync>) -> Self {
122 self.custom_sampler = Some(sampler);
123 self
124 }
125
126 pub fn with_scheduler(mut self, name: impl Into<String>) -> Self {
128 self.scheduler_name = Some(name.into());
129 self
130 }
131
132 pub fn with_custom_scheduler(mut self, scheduler: Arc<dyn Scheduler + Send + Sync>) -> Self {
134 self.custom_scheduler = Some(scheduler);
135 self
136 }
137
138 pub fn with_kv_cache(mut self, name: impl Into<String>) -> Self {
140 self.kv_cache_name = Some(name.into());
141 self
142 }
143
144 pub fn with_custom_kv_cache(mut self, kv_cache: Arc<dyn KvCacheManager + Send + Sync>) -> Self {
146 self.custom_kv_cache = Some(kv_cache);
147 self
148 }
149
150 pub fn with_custom_recurrent_state_manager(
152 mut self,
153 manager: Arc<dyn RecurrentStateManager + Send + Sync>,
154 ) -> Self {
155 self.custom_recurrent_state_manager = Some(manager);
156 self
157 }
158
159 pub fn with_executor(mut self, name: impl Into<String>) -> Self {
161 self.executor_name = Some(name.into());
162 self
163 }
164
165 pub fn with_custom_executor(mut self, executor: Arc<dyn ModelExecutor + Send + Sync>) -> Self {
167 self.custom_executor = Some(executor);
168 self
169 }
170
171 fn resolve_tokenizer_name(&self) -> String {
173 if let Some(ref name) = self.tokenizer_name {
174 return name.clone();
175 }
176
177 if self.has_typed_model_path() || self.config.runtime.model_path.is_some() {
179 return "huggingface".to_string();
180 }
181
182 "stub".to_string()
183 }
184
185 fn resolve_sampler_name(&self) -> String {
187 if let Some(ref name) = self.sampler_name {
188 return name.clone();
189 }
190
191 "multinomial".to_string()
193 }
194
195 fn resolve_kv_cache_name(&self) -> String {
197 if let Some(ref name) = self.kv_cache_name {
198 return name.clone();
199 }
200
201 match self.config.kv_cache.cache_type {
202 ferrum_types::KvCacheType::Contiguous => "default".to_string(),
203 ferrum_types::KvCacheType::Paged => "paged".to_string(),
204 ferrum_types::KvCacheType::Tree => "default".to_string(), }
206 }
207
208 fn resolve_executor_name(&self) -> String {
210 if let Some(ref name) = self.executor_name {
211 return name.clone();
212 }
213
214 if self.has_typed_model_path() || self.config.runtime.model_path.is_some() {
216 return "llm".to_string();
217 }
218
219 "stub".to_string()
220 }
221
222 fn has_typed_model_path(&self) -> bool {
223 self.model_sources.is_some()
224 || self
225 .config
226 .backend
227 .backend_options
228 .get("model_path")
229 .and_then(|value| value.as_str())
230 .is_some()
231 }
232
233 pub async fn build(self) -> Result<Box<dyn LlmInferenceEngine + Send + Sync>> {
235 info!(
236 "Building inference engine for model: {}",
237 self.config.model.model_id
238 );
239
240 if self.scheduler_name.is_some() || self.custom_scheduler.is_some() {
241 return Err(FerrumError::config(
242 "EngineBuilder scheduler component overrides are no longer accepted; configure the typed EngineConfig.scheduler used by ContinuousBatchScheduler",
243 ));
244 }
245
246 let tokenizer_name = self.resolve_tokenizer_name();
248 let sampler_name = self.resolve_sampler_name();
249 let kv_cache_name = self.resolve_kv_cache_name();
250 let executor_name = self.resolve_executor_name();
251 let explicit_kv_cache_override = self.kv_cache_name.is_some();
252
253 let component_config = ComponentConfig::from_engine_config_and_product_model(
254 &self.config,
255 self.model_sources.clone(),
256 self.defined_model.clone(),
257 );
258 validate_layer_split_plan(&component_config)?;
259 let typed_model_path = component_config.get_string_option("model_path");
260 let has_model_path = typed_model_path.is_some() || self.config.runtime.model_path.is_some();
261 let registry = self.registry.clone();
262 let mut config = self.config;
263
264 let custom_tokenizer = self.custom_tokenizer;
270 let custom_sampler = self.custom_sampler;
271 let custom_kv_cache = self.custom_kv_cache;
272 let custom_recurrent_state_manager = self.custom_recurrent_state_manager;
273 let custom_executor = self.custom_executor;
274
275 let tokenizer = if let Some(tokenizer) = custom_tokenizer {
277 debug!("Using custom tokenizer");
278 tokenizer
279 } else {
280 debug!("Creating tokenizer: {}", tokenizer_name);
281
282 match registry
284 .create_tokenizer(&tokenizer_name, &component_config)
285 .await
286 {
287 Ok(t) => t,
288 Err(e) => {
289 if has_model_path {
290 return Err(FerrumError::config(format!(
291 "Failed to create tokenizer '{}' in model mode: {}",
292 tokenizer_name, e
293 )));
294 }
295
296 tracing::warn!(
297 "Failed to create tokenizer '{}': {}, falling back to stub",
298 tokenizer_name,
299 e
300 );
301 registry.create_tokenizer("stub", &component_config).await?
302 }
303 }
304 };
305
306 let sampler = if let Some(sampler) = custom_sampler {
308 debug!("Using custom sampler");
309 sampler
310 } else {
311 debug!("Creating sampler: {}", sampler_name);
312 registry
313 .create_sampler(&sampler_name, &component_config)
314 .await?
315 };
316
317 let executor = if let Some(executor) = custom_executor {
321 debug!("Using custom executor");
322 executor
323 } else {
324 debug!("Creating executor: {}", executor_name);
325
326 match registry
328 .create_executor(&executor_name, &component_config)
329 .await
330 {
331 Ok(e) => e,
332 Err(err) => {
333 if has_model_path {
334 return Err(FerrumError::config(format!(
335 "Failed to create executor '{}' in model mode: {}",
336 executor_name, err
337 )));
338 }
339
340 tracing::warn!(
341 "Failed to create executor '{}': {}, falling back to stub",
342 executor_name,
343 err
344 );
345 registry.create_executor("stub", &component_config).await?
346 }
347 }
348 };
349 if let Some(plan) = executor.startup_memory_plan() {
350 plan.apply_to_engine_config(&mut config)
351 .map_err(FerrumError::config)?;
352 }
353 let execution_resource_authority = executor.execution_resource_authority();
354
355 let (kv_cache, recurrent_state_manager) = match execution_resource_authority {
356 ferrum_interfaces::model_executor::ExecutionResourceAuthority::PlanRuntime => {
357 if explicit_kv_cache_override || custom_kv_cache.is_some() {
358 return Err(FerrumError::config(
359 "plan runtime cannot be combined with a legacy engine KV-cache override",
360 ));
361 }
362 if custom_recurrent_state_manager.is_some() {
363 return Err(FerrumError::config(
364 "plan runtime cannot be combined with a legacy engine recurrent-state manager",
365 ));
366 }
367 if executor.resolved_model_plan().is_none() {
368 return Err(FerrumError::config(
369 "plan-runtime executor did not expose its authoritative ResolvedModelPlan",
370 ));
371 }
372 (None, None)
373 }
374 ferrum_interfaces::model_executor::ExecutionResourceAuthority::LegacyEngine => {
375 let kv_cache = if let Some(kv_cache) = custom_kv_cache {
376 debug!("Using custom KV cache");
377 kv_cache
378 } else {
379 debug!("Creating KV cache: {}", kv_cache_name);
380 registry
381 .create_kv_cache(&kv_cache_name, &component_config)
382 .await?
383 };
384 let recurrent_state_manager = custom_recurrent_state_manager
385 .or_else(|| default_recurrent_state_manager(&config));
386 (Some(kv_cache), recurrent_state_manager)
387 }
388 };
389
390 info!("All components created, building ContinuousBatchEngine");
392
393 let cb_scheduler = Arc::new(
394 ferrum_scheduler::implementations::ContinuousBatchScheduler::new(
395 config.scheduler.clone(),
396 ),
397 );
398
399 let tensor_factory: Arc<dyn TensorFactory> = Arc::new(
401 crate::tensor_factory::candle::CandleTensorFactory::new(config.backend.device.clone()),
402 );
403
404 let spec_draft = component_config
410 .get_string_option("spec_draft")
411 .or_else(|| config.runtime.spec_draft.clone());
412 if execution_resource_authority
413 == ferrum_interfaces::model_executor::ExecutionResourceAuthority::PlanRuntime
414 && spec_draft.is_some()
415 {
416 return Err(FerrumError::unsupported(
417 "speculative decoding is not yet part of the plan-runtime contract",
418 ));
419 }
420 let spec_n = component_config
421 .get_option::<usize>("spec_n")
422 .unwrap_or(config.runtime.spec_n.unwrap_or(4));
423 let (draft_executor, spec_config) = match spec_draft.as_ref() {
424 Some(draft_path) => {
425 info!("Speculative decoding: loading draft model from {draft_path}");
426 let mut draft_cfg = component_config.clone();
427 draft_cfg.component_options.insert(
428 "model_path".to_string(),
429 serde_json::Value::String(draft_path.to_string()),
430 );
431 let draft = registry
432 .create_executor(&executor_name, &draft_cfg)
433 .await
434 .map_err(|error| {
435 FerrumError::config(format!(
436 "requested speculative draft executor failed to load: {error}"
437 ))
438 })?;
439 if draft.execution_resource_authority() != execution_resource_authority {
440 return Err(FerrumError::config(
441 "target and speculative draft executors declare different resource authority",
442 ));
443 }
444 (
445 Some(draft),
446 Some(crate::speculative::SpeculativeDecodingConfig {
447 num_speculative_tokens: spec_n,
448 temperature: 1.0,
449 }),
450 )
451 }
452 _ => (None, None),
453 };
454
455 let engine = match execution_resource_authority {
458 ferrum_interfaces::model_executor::ExecutionResourceAuthority::PlanRuntime => {
459 crate::ContinuousBatchEngine::new_plan_runtime(
460 config,
461 cb_scheduler,
462 tokenizer,
463 sampler,
464 Arc::clone(&executor),
465 tensor_factory,
466 )?
467 }
468 ferrum_interfaces::model_executor::ExecutionResourceAuthority::LegacyEngine => {
469 let kv_cache = kv_cache.ok_or_else(|| {
470 FerrumError::internal("legacy-engine composition lost its KV-cache manager")
471 })?;
472 crate::ContinuousBatchEngine::new_with_speculation_and_recurrent_state_manager(
473 config,
474 cb_scheduler,
475 tokenizer,
476 sampler,
477 kv_cache,
478 Arc::clone(&executor),
479 tensor_factory,
480 draft_executor.clone(),
481 spec_config,
482 recurrent_state_manager,
483 )?
484 }
485 };
486
487 let startup_result = async {
491 executor.prepare_startup().await?;
492 if let Some(draft) = draft_executor.as_ref() {
493 draft.prepare_startup().await?;
494 }
495 Ok(())
496 }
497 .await;
498 if let Err(startup_error) = startup_result {
499 if let Err(shutdown_error) = engine.shutdown().await {
500 tracing::warn!(
501 "Failed to close engine resources after startup rejection: {shutdown_error}"
502 );
503 }
504 return Err(startup_error);
505 }
506 Ok(Box::new(engine))
507 }
508}
509
510fn default_recurrent_state_manager(
511 config: &EngineConfig,
512) -> Option<Arc<dyn RecurrentStateManager + Send + Sync>> {
513 let total_batch_slots = config
514 .runtime
515 .recurrent_state_max_slots
516 .unwrap_or(usize::MAX);
517 Some(
518 Arc::new(crate::recurrent_state::InMemoryRecurrentStateManager::new(
519 crate::recurrent_state::InMemoryRecurrentStateConfig {
520 total_memory_bytes: usize::MAX,
521 total_batch_slots,
522 },
523 )) as Arc<dyn RecurrentStateManager + Send + Sync>,
524 )
525}
526
527fn validate_layer_split_plan(component_config: &ComponentConfig) -> Result<()> {
528 if component_config
529 .get_string_option("selected_distributed_strategy")
530 .as_deref()
531 != Some("layer_split")
532 {
533 return Ok(());
534 }
535 let requested = component_config
536 .get_option::<Vec<usize>>("requested_gpu_devices")
537 .unwrap_or_default();
538 let selected = component_config
539 .get_option::<Vec<usize>>("selected_gpu_devices")
540 .unwrap_or_default();
541 let plan_raw = component_config.get_string_option("selected_layer_split_plan");
542 let parsed_plan = if let Some(stages) = component_config
543 .component_options
544 .get("selected_layer_split_stages")
545 {
546 crate::layer_split::parse_layer_split_stage_documents(stages)?
547 } else {
548 let plan_raw = plan_raw.as_deref().ok_or_else(|| {
549 FerrumError::config(
550 "selected_distributed_strategy=layer_split requires selected_layer_split_plan",
551 )
552 })?;
553 crate::layer_split::parse_layer_split_plan(plan_raw)?
554 };
555 crate::layer_split::validate_layer_split_plan_for_devices(&parsed_plan, &selected)?;
556 let execution_plan = parsed_plan.to_execution_plan();
557 let stage_ranges = execution_plan
558 .layer_distribution
559 .stage_layers
560 .iter()
561 .map(|range| format!("{}-{}", range.start, range.end.saturating_sub(1)))
562 .collect::<Vec<_>>()
563 .join(",");
564 let plan_label = plan_raw.unwrap_or_else(|| format!("{:?}", parsed_plan.stages));
565 tracing::info!(
566 "validated CUDA layer_split plan: requested_gpu_devices={requested:?} selected_gpu_devices={selected:?} selected_layer_split_plan={plan_label} total_layers={} pipeline_stages={} stage_ranges={stage_ranges} communication_backend={}",
567 parsed_plan.total_layers(),
568 execution_plan.parallel_config.pipeline_parallel_size,
569 execution_plan.parallel_config.communication_backend,
570 );
571 Ok(())
572}
573
574pub async fn create_engine(
576 config: EngineConfig,
577) -> Result<Box<dyn LlmInferenceEngine + Send + Sync>> {
578 EngineBuilder::new(config).build().await
579}
580
581pub async fn create_product_engine(
583 config: EngineConfig,
584 sources: Arc<ProductionModelSourceBundle>,
585) -> Result<Box<dyn LlmInferenceEngine + Send + Sync>> {
586 EngineBuilder::new(config)
587 .with_model_sources(sources)
588 .build()
589 .await
590}
591
592pub async fn create_defined_product_engine(
595 config: EngineConfig,
596 prepared: Arc<DefinedProductionModel>,
597) -> Result<Box<dyn LlmInferenceEngine + Send + Sync>> {
598 EngineBuilder::new(config)
599 .with_defined_model(prepared)
600 .build()
601 .await
602}
603
604#[cfg(test)]
609mod tests {
610 use super::*;
611 use ferrum_interfaces::{
612 model_executor::{
613 DecodeInput, DecodeOutput, ExecutionResourceAuthority, ExecutorCapabilities,
614 ExecutorStatus, PlanRuntimeResourceSnapshot, PrefillInput, PrefillOutput,
615 },
616 vnext::ExecutionEventSink,
617 RecurrentStateHandle, RecurrentStateManager, RecurrentStateManagerStats,
618 RecurrentStateSpec, RecurrentStateTensorSpec,
619 };
620 use ferrum_types::{DataType, Device, RequestId};
621 use std::sync::{
622 atomic::{AtomicBool, AtomicUsize, Ordering},
623 Mutex,
624 };
625
626 #[derive(Debug)]
627 struct NoopRecurrentStateManager;
628
629 struct PlanRuntimeBuilderExecutor {
630 inner: ferrum_testkit::MockModelExecutor,
631 }
632
633 struct StartupProbeExecutor {
634 inner: ferrum_testkit::MockModelExecutor,
635 calls: Arc<AtomicUsize>,
636 fail: bool,
637 }
638
639 struct ProfileStartupProbeExecutor {
640 inner: ferrum_testkit::MockModelExecutor,
641 event_sink: Mutex<Option<Arc<dyn ExecutionEventSink>>>,
642 saw_sink_during_startup: Arc<AtomicBool>,
643 }
644
645 #[async_trait::async_trait]
646 impl ModelExecutor for StartupProbeExecutor {
647 fn info(&self) -> &ferrum_types::ModelInfo {
648 self.inner.info()
649 }
650
651 async fn prepare_startup(&self) -> Result<()> {
652 self.calls.fetch_add(1, Ordering::Relaxed);
653 if self.fail {
654 return Err(FerrumError::backend("startup preparation rejected"));
655 }
656 Ok(())
657 }
658
659 async fn prefill(&self, input: &PrefillInput) -> Result<PrefillOutput> {
660 self.inner.prefill(input).await
661 }
662
663 async fn decode(&self, input: &DecodeInput) -> Result<DecodeOutput> {
664 self.inner.decode(input).await
665 }
666
667 fn capabilities(&self) -> ExecutorCapabilities {
668 self.inner.capabilities()
669 }
670
671 fn status(&self) -> ExecutorStatus {
672 self.inner.status()
673 }
674 }
675
676 #[async_trait::async_trait]
677 impl ModelExecutor for ProfileStartupProbeExecutor {
678 fn info(&self) -> &ferrum_types::ModelInfo {
679 self.inner.info()
680 }
681
682 async fn prepare_startup(&self) -> Result<()> {
683 use ferrum_interfaces::vnext::{ExecutionEventEmitter, TrustedExecutionEventContext};
684
685 let sink = self
686 .event_sink
687 .lock()
688 .expect("profile startup probe sink lock")
689 .clone();
690 self.saw_sink_during_startup
691 .store(sink.is_some(), Ordering::Release);
692 let Some(sink) = sink else {
693 return Ok(());
694 };
695 let (run_id, request_id, event) = startup_profile_test_event();
696 ExecutionEventEmitter::from_shared(sink, run_id.clone(), request_id.clone())
697 .emit(
698 event,
699 &TrustedExecutionEventContext::pre_plan(&run_id, &request_id),
700 )
701 .map_err(|error| {
702 FerrumError::internal(format!("emit startup profile probe: {error}"))
703 })
704 }
705
706 fn attach_execution_event_sink(&self, sink: Arc<dyn ExecutionEventSink>) {
707 *self
708 .event_sink
709 .lock()
710 .expect("profile startup probe sink lock") = Some(sink);
711 }
712
713 async fn prefill(&self, input: &PrefillInput) -> Result<PrefillOutput> {
714 self.inner.prefill(input).await
715 }
716
717 async fn decode(&self, input: &DecodeInput) -> Result<DecodeOutput> {
718 self.inner.decode(input).await
719 }
720
721 fn capabilities(&self) -> ExecutorCapabilities {
722 self.inner.capabilities()
723 }
724
725 fn status(&self) -> ExecutorStatus {
726 self.inner.status()
727 }
728 }
729
730 fn startup_profile_test_event() -> (
731 ferrum_interfaces::vnext::RunId,
732 ferrum_interfaces::vnext::RequestIdentity,
733 ferrum_interfaces::vnext::ExecutionEvent,
734 ) {
735 use ferrum_interfaces::vnext::{
736 ExecutionEvent, ExecutionEventDetail, ExecutionEventKind, ExecutionIdentityEnvelope,
737 ExecutionIdentityParts, ExecutionPhase, MonotonicTimestamp, RequestIdentity, RunId,
738 SpanId, EXECUTION_IDENTITY_VERSION,
739 };
740
741 let run_id = RunId::new("run.vnext.builder-startup-profile").unwrap();
742 let request_id = RequestIdentity::new("request.vnext.builder-startup-profile").unwrap();
743 let event = ExecutionEvent::new(
744 MonotonicTimestamp {
745 nanos_since_run_start: 1,
746 },
747 ExecutionPhase::Resolution,
748 ExecutionEventKind::RequestAccepted,
749 ExecutionIdentityEnvelope::new(ExecutionIdentityParts {
750 version: EXECUTION_IDENTITY_VERSION,
751 run_id: run_id.clone(),
752 request_id: request_id.clone(),
753 sequence: 1,
754 plan_id: None,
755 plan_hash: None,
756 frame_id: None,
757 node_invocation_id: None,
758 node_id: None,
759 operation_id: None,
760 provider_id: None,
761 device_id: None,
762 resource_pool_id: None,
763 resource_pool_identity_fingerprint: None,
764 provisioning_run_id: None,
765 provisioning_request_id: None,
766 transaction_id: None,
767 active_sequence_slot: None,
768 admission_generation: None,
769 activation_epoch: None,
770 runtime_implementation_fingerprint: None,
771 active_sequence_fingerprint: None,
772 completed_sequence_fingerprint: None,
773 aborted_sequence_fingerprint: None,
774 resource_id: None,
775 resource_generation: None,
776 resource_batch_fingerprint: None,
777 span_id: SpanId::new("vnext/request/builder-startup-profile").unwrap(),
778 parent_span_id: None,
779 async_links: Vec::new(),
780 })
781 .unwrap(),
782 ExecutionEventDetail::None,
783 )
784 .unwrap();
785 (run_id, request_id, event)
786 }
787
788 #[async_trait::async_trait]
789 impl ModelExecutor for PlanRuntimeBuilderExecutor {
790 fn info(&self) -> &ferrum_types::ModelInfo {
791 self.inner.info()
792 }
793
794 fn execution_resource_authority(&self) -> ExecutionResourceAuthority {
795 ExecutionResourceAuthority::PlanRuntime
796 }
797
798 fn plan_runtime_resource_snapshot(&self) -> Result<Option<PlanRuntimeResourceSnapshot>> {
799 PlanRuntimeResourceSnapshot::new(1_000, 900, 700, 700, 400, 300, 200, 0, 0).map(Some)
800 }
801
802 async fn prefill(&self, input: &PrefillInput) -> Result<PrefillOutput> {
803 self.inner.prefill(input).await
804 }
805
806 async fn decode(&self, input: &DecodeInput) -> Result<DecodeOutput> {
807 self.inner.decode(input).await
808 }
809
810 fn capabilities(&self) -> ExecutorCapabilities {
811 self.inner.capabilities()
812 }
813
814 fn status(&self) -> ExecutorStatus {
815 self.inner.status()
816 }
817 }
818
819 struct CountingKvFactory {
820 calls: Arc<AtomicUsize>,
821 }
822
823 #[async_trait::async_trait]
824 impl crate::registry::ComponentFactory<Arc<dyn KvCacheManager + Send + Sync>>
825 for CountingKvFactory
826 {
827 async fn create(
828 &self,
829 _config: &ComponentConfig,
830 ) -> Result<Arc<dyn KvCacheManager + Send + Sync>> {
831 self.calls.fetch_add(1, Ordering::Relaxed);
832 Ok(Arc::new(ferrum_testkit::MockKvCacheManager::new(8)))
833 }
834
835 fn metadata(&self) -> crate::registry::ComponentMetadata {
836 crate::registry::ComponentMetadata::default()
837 }
838 }
839
840 #[async_trait::async_trait]
841 impl RecurrentStateManager for NoopRecurrentStateManager {
842 async fn allocate(
843 &self,
844 _spec: &RecurrentStateSpec,
845 ) -> Result<Arc<dyn RecurrentStateHandle>> {
846 Err(FerrumError::unsupported(
847 "noop recurrent-state manager does not allocate",
848 ))
849 }
850
851 async fn deallocate(&self, _request_id: RequestId) -> Result<()> {
852 Ok(())
853 }
854
855 fn can_allocate(&self, _spec: &RecurrentStateSpec) -> bool {
856 false
857 }
858
859 fn get_handle(&self, _request_id: RequestId) -> Option<Arc<dyn RecurrentStateHandle>> {
860 None
861 }
862
863 fn list_handles(&self) -> Vec<(RequestId, Arc<dyn RecurrentStateHandle>)> {
864 Vec::new()
865 }
866
867 fn stats(&self) -> RecurrentStateManagerStats {
868 RecurrentStateManagerStats {
869 total_memory_bytes: 0,
870 used_memory_bytes: 0,
871 active_states: 0,
872 active_state_tensors: 0,
873 total_batch_slots: 0,
874 used_batch_slots: 0,
875 allocation_count: 0,
876 allocation_failures: 0,
877 eviction_count: 0,
878 }
879 }
880
881 async fn reset(&self) -> Result<()> {
882 Ok(())
883 }
884 }
885
886 #[test]
887 fn test_builder_creation() {
888 let config = EngineConfig::default();
889 let builder = EngineBuilder::new(config);
890
891 assert!(builder.tokenizer_name.is_none());
892 assert!(builder.custom_recurrent_state_manager.is_none());
893 }
894
895 #[test]
896 fn test_builder_with_overrides() {
897 let config = EngineConfig::default();
898 let builder = EngineBuilder::new(config)
899 .with_tokenizer("custom_tokenizer")
900 .with_sampler("greedy")
901 .with_scheduler("priority")
902 .with_kv_cache("paged")
903 .with_executor("custom_executor");
904
905 assert_eq!(builder.tokenizer_name, Some("custom_tokenizer".to_string()));
906 assert_eq!(builder.sampler_name, Some("greedy".to_string()));
907 assert_eq!(builder.scheduler_name, Some("priority".to_string()));
908 assert_eq!(builder.kv_cache_name, Some("paged".to_string()));
909 assert_eq!(builder.executor_name, Some("custom_executor".to_string()));
910 }
911
912 #[test]
913 fn test_builder_with_custom_recurrent_state_manager() {
914 let config = EngineConfig::default();
915 let manager = Arc::new(NoopRecurrentStateManager);
916 let builder = EngineBuilder::new(config).with_custom_recurrent_state_manager(manager);
917
918 assert!(builder.custom_recurrent_state_manager.is_some());
919 }
920
921 #[test]
922 fn test_builder_typed_model_path_selects_model_components() {
923 let mut config = EngineConfig::default();
924 config.backend.backend_options.insert(
925 "model_path".to_string(),
926 serde_json::Value::String("/models/target".to_string()),
927 );
928 let builder = EngineBuilder::new(config);
929
930 assert!(builder.has_typed_model_path());
931 assert_eq!(builder.resolve_tokenizer_name(), "huggingface");
932 assert_eq!(builder.resolve_executor_name(), "llm");
933 }
934
935 #[test]
936 fn test_builder_retains_one_typed_source_bundle_for_components() {
937 let root = std::env::temp_dir().join(format!(
938 "ferrum-builder-source-bundle-{}-{}",
939 std::process::id(),
940 std::time::SystemTime::now()
941 .duration_since(std::time::UNIX_EPOCH)
942 .unwrap()
943 .as_nanos()
944 ));
945 std::fs::create_dir_all(&root).unwrap();
946 std::fs::write(
947 root.join("config.json"),
948 br#"{"architectures":["Fixture"]}"#,
949 )
950 .unwrap();
951 std::fs::write(root.join("tokenizer.json"), br#"{"version":"1.0"}"#).unwrap();
952 std::fs::write(root.join("model.safetensors"), b"fixture").unwrap();
953 let original = ferrum_interfaces::vnext::OriginalModelSource {
954 kind: ferrum_interfaces::vnext::ModelSourceKind::LocalDirectory,
955 location: root.display().to_string(),
956 requested_revision: None,
957 };
958 let sources = Arc::new(
959 ProductionModelSourceBundle::open(
960 &root,
961 &root,
962 ferrum_models::vnext::ProductionWeightArtifact::safetensors_directory(&root),
963 ferrum_interfaces::vnext::OriginalModelSources {
964 semantic: original.clone(),
965 tokenizer: original.clone(),
966 weights: original,
967 },
968 )
969 .unwrap(),
970 );
971
972 let builder =
973 EngineBuilder::new(EngineConfig::default()).with_model_sources(Arc::clone(&sources));
974 assert!(builder.has_typed_model_path());
975 assert!(Arc::ptr_eq(
976 builder.model_sources.as_ref().unwrap(),
977 &sources
978 ));
979 assert_eq!(builder.resolve_tokenizer_name(), "huggingface");
980 assert_eq!(builder.resolve_executor_name(), "llm");
981 std::fs::remove_dir_all(root).unwrap();
982 }
983
984 #[test]
985 fn test_builder_typed_spec_options_parse_from_component_config() {
986 let mut config = EngineConfig::default();
987 config.backend.backend_options.insert(
988 "model_path".to_string(),
989 serde_json::Value::String("/models/target".to_string()),
990 );
991 config.backend.backend_options.insert(
992 "spec_draft".to_string(),
993 serde_json::Value::String("/models/draft".to_string()),
994 );
995 config.backend.backend_options.insert(
996 "spec_n".to_string(),
997 serde_json::Value::Number(serde_json::Number::from(6)),
998 );
999 let component_config = ComponentConfig::from_engine_config(&config);
1000
1001 assert_eq!(
1002 component_config.get_string_option("spec_draft").as_deref(),
1003 Some("/models/draft")
1004 );
1005 assert_eq!(component_config.get_option::<usize>("spec_n"), Some(6));
1006 }
1007
1008 #[test]
1009 fn test_builder_cuda_recurrent_state_manager_uses_recurrent_state_slot_cap() {
1010 let mut config = EngineConfig::default();
1011 config.backend.device = Device::CUDA(0);
1012 config.runtime.recurrent_state_max_slots = Some(2);
1013 let manager = default_recurrent_state_manager(&config)
1014 .expect("cuda product path should install admission recurrent-state manager");
1015 let spec = |request_id| RecurrentStateSpec {
1016 request_id,
1017 num_layers: 1,
1018 tensors: vec![RecurrentStateTensorSpec::new(
1019 0,
1020 "delta_state",
1021 vec![1, 1, 1],
1022 DataType::FP32,
1023 )],
1024 device: Device::CUDA(0),
1025 max_batch_slots: 1,
1026 };
1027
1028 tokio_test::block_on(manager.allocate(&spec(RequestId::new()))).unwrap();
1029 tokio_test::block_on(manager.allocate(&spec(RequestId::new()))).unwrap();
1030 let err = tokio_test::block_on(manager.allocate(&spec(RequestId::new())))
1031 .expect_err("third recurrent allocation should exceed the two-slot cap");
1032
1033 assert!(matches!(err, FerrumError::ResourceExhausted { .. }));
1034 let stats = manager.stats();
1035 assert_eq!(stats.total_batch_slots, 2);
1036 assert_eq!(stats.used_batch_slots, 2);
1037 assert_eq!(stats.allocation_failures, 1);
1038 }
1039
1040 #[test]
1041 fn test_builder_validates_layer_split_plan_without_executor_reject() {
1042 let mut config = EngineConfig::default();
1043 config.backend.backend_options.insert(
1044 "model_path".to_string(),
1045 serde_json::Value::String("/models/target".to_string()),
1046 );
1047 config.backend.backend_options.insert(
1048 "selected_distributed_strategy".to_string(),
1049 serde_json::Value::String("layer_split".to_string()),
1050 );
1051 config.backend.backend_options.insert(
1052 "requested_gpu_devices".to_string(),
1053 serde_json::json!([0, 1]),
1054 );
1055 config.backend.backend_options.insert(
1056 "selected_gpu_devices".to_string(),
1057 serde_json::json!([0, 1]),
1058 );
1059 config.backend.backend_options.insert(
1060 "selected_layer_split_plan".to_string(),
1061 serde_json::Value::String(
1062 "stage0:cuda:0:layers=0-39;stage1:cuda:1:layers=40-79".to_string(),
1063 ),
1064 );
1065 config.backend.backend_options.insert(
1066 "selected_layer_split_stages".to_string(),
1067 serde_json::json!([
1068 {"stage": 0, "device": 0, "layer_start": 0, "layer_end": 39},
1069 {"stage": 1, "device": 1, "layer_start": 40, "layer_end": 79}
1070 ]),
1071 );
1072 let component_config = ComponentConfig::from_engine_config(&config);
1073
1074 validate_layer_split_plan(&component_config).unwrap();
1075 }
1076
1077 #[tokio::test]
1078 async fn test_builder_rejects_invalid_layer_split_plan_before_executor_build() {
1079 let mut config = EngineConfig::default();
1080 config.backend.backend_options.insert(
1081 "model_path".to_string(),
1082 serde_json::Value::String("/models/target".to_string()),
1083 );
1084 config.backend.backend_options.insert(
1085 "selected_distributed_strategy".to_string(),
1086 serde_json::Value::String("layer_split".to_string()),
1087 );
1088 config.backend.backend_options.insert(
1089 "requested_gpu_devices".to_string(),
1090 serde_json::json!([0, 1]),
1091 );
1092 config.backend.backend_options.insert(
1093 "selected_gpu_devices".to_string(),
1094 serde_json::json!([0, 1]),
1095 );
1096 config.backend.backend_options.insert(
1097 "selected_layer_split_plan".to_string(),
1098 serde_json::Value::String(
1099 "stage0:cuda:0:layers=auto;stage1:cuda:1:layers=auto".to_string(),
1100 ),
1101 );
1102
1103 let err = match EngineBuilder::new(config).build().await {
1104 Ok(_) => panic!("layer_split build unexpectedly succeeded"),
1105 Err(err) => err,
1106 };
1107 assert!(err.to_string().contains("expected START-END"));
1108 }
1109
1110 #[test]
1111 fn test_resolve_defaults() {
1112 let config = EngineConfig::default();
1113 let builder = EngineBuilder::new(config);
1114
1115 assert_eq!(builder.resolve_sampler_name(), "multinomial");
1116 assert_eq!(builder.resolve_kv_cache_name(), "default");
1117 }
1118
1119 #[tokio::test]
1120 async fn test_build_with_defaults() {
1121 let config = EngineConfig::default();
1122 let result = EngineBuilder::new(config).build().await;
1123
1124 assert!(result.is_ok());
1126 }
1127
1128 #[tokio::test]
1129 async fn startup_preparation_runs_once_and_blocks_engine_publication_on_failure() {
1130 let success_calls = Arc::new(AtomicUsize::new(0));
1131 let success: Arc<dyn ModelExecutor + Send + Sync> = Arc::new(StartupProbeExecutor {
1132 inner: ferrum_testkit::MockModelExecutor::instant(128),
1133 calls: Arc::clone(&success_calls),
1134 fail: false,
1135 });
1136 EngineBuilder::new(EngineConfig::default())
1137 .with_custom_executor(success)
1138 .build()
1139 .await
1140 .expect("successful startup preparation builds the engine");
1141 assert_eq!(success_calls.load(Ordering::Relaxed), 1);
1142
1143 let failure_calls = Arc::new(AtomicUsize::new(0));
1144 let failure: Arc<dyn ModelExecutor + Send + Sync> = Arc::new(StartupProbeExecutor {
1145 inner: ferrum_testkit::MockModelExecutor::instant(128),
1146 calls: Arc::clone(&failure_calls),
1147 fail: true,
1148 });
1149 let error = EngineBuilder::new(EngineConfig::default())
1150 .with_custom_executor(failure)
1151 .build()
1152 .await
1153 .err()
1154 .expect("failed startup preparation must stop engine construction");
1155 assert!(error.to_string().contains("startup preparation rejected"));
1156 assert_eq!(failure_calls.load(Ordering::Relaxed), 1);
1157 }
1158
1159 #[tokio::test]
1160 async fn product_profile_captures_startup_events_before_engine_readiness() {
1161 let trace_path = std::env::temp_dir().join(format!(
1162 "ferrum-builder-startup-profile-{}-{}.jsonl",
1163 std::process::id(),
1164 std::time::SystemTime::now()
1165 .duration_since(std::time::UNIX_EPOCH)
1166 .unwrap()
1167 .as_nanos()
1168 ));
1169 let _ = std::fs::remove_file(&trace_path);
1170 let saw_sink_during_startup = Arc::new(AtomicBool::new(false));
1171 let executor: Arc<dyn ModelExecutor + Send + Sync> =
1172 Arc::new(ProfileStartupProbeExecutor {
1173 inner: ferrum_testkit::MockModelExecutor::instant(128),
1174 event_sink: Mutex::new(None),
1175 saw_sink_during_startup: Arc::clone(&saw_sink_during_startup),
1176 });
1177 let mut config = EngineConfig::default();
1178 config.runtime.profile_jsonl = Some(trace_path.clone());
1179 config.runtime.profile_entrypoint = Some(ferrum_types::ProfileEntrypoint::Run);
1180
1181 let engine = EngineBuilder::new(config)
1182 .with_custom_executor(executor)
1183 .build()
1184 .await
1185 .expect("profile-enabled startup builds the engine");
1186 assert!(saw_sink_during_startup.load(Ordering::Acquire));
1187 engine.shutdown().await.unwrap();
1188
1189 let startup_events = std::fs::read_to_string(&trace_path)
1190 .unwrap()
1191 .lines()
1192 .map(|line| serde_json::from_str::<serde_json::Value>(line).unwrap())
1193 .filter(|event| event["phase"] == "vnext.request_accepted")
1194 .count();
1195 assert_eq!(startup_events, 1);
1196 let _ = std::fs::remove_file(trace_path);
1197 }
1198
1199 #[tokio::test]
1200 async fn product_without_profile_does_not_attach_execution_event_sink() {
1201 let saw_sink_during_startup = Arc::new(AtomicBool::new(false));
1202 let executor: Arc<dyn ModelExecutor + Send + Sync> =
1203 Arc::new(ProfileStartupProbeExecutor {
1204 inner: ferrum_testkit::MockModelExecutor::instant(128),
1205 event_sink: Mutex::new(None),
1206 saw_sink_during_startup: Arc::clone(&saw_sink_during_startup),
1207 });
1208
1209 let engine = EngineBuilder::new(EngineConfig::default())
1210 .with_custom_executor(executor)
1211 .build()
1212 .await
1213 .expect("profile-disabled engine builds");
1214 assert!(!saw_sink_during_startup.load(Ordering::Acquire));
1215 engine.shutdown().await.unwrap();
1216 }
1217
1218 #[tokio::test]
1219 async fn plan_runtime_without_resolved_plan_rejects_before_legacy_kv_factory() {
1220 let calls = Arc::new(AtomicUsize::new(0));
1221 let registry = Arc::new(ComponentRegistry::with_defaults());
1222 registry.register_kv_cache_factory(
1223 "default",
1224 Arc::new(CountingKvFactory {
1225 calls: Arc::clone(&calls),
1226 }),
1227 );
1228 let executor: Arc<dyn ModelExecutor + Send + Sync> = Arc::new(PlanRuntimeBuilderExecutor {
1229 inner: ferrum_testkit::MockModelExecutor::instant(128),
1230 });
1231
1232 let result = EngineBuilder::with_registry(EngineConfig::default(), registry)
1233 .with_custom_executor(executor)
1234 .build()
1235 .await;
1236
1237 let error = result
1238 .err()
1239 .expect("plan runtime without a resolved plan must fail closed");
1240 assert!(error
1241 .to_string()
1242 .contains("authoritative ResolvedModelPlan"));
1243 assert_eq!(calls.load(Ordering::Relaxed), 0);
1244 }
1245
1246 #[tokio::test]
1247 async fn plan_runtime_build_rejects_legacy_resource_override() {
1248 let executor: Arc<dyn ModelExecutor + Send + Sync> = Arc::new(PlanRuntimeBuilderExecutor {
1249 inner: ferrum_testkit::MockModelExecutor::instant(128),
1250 });
1251 let kv_cache: Arc<dyn KvCacheManager + Send + Sync> =
1252 Arc::new(ferrum_testkit::MockKvCacheManager::new(8));
1253
1254 let error = EngineBuilder::new(EngineConfig::default())
1255 .with_custom_executor(executor)
1256 .with_custom_kv_cache(kv_cache)
1257 .build()
1258 .await
1259 .err()
1260 .expect("plan runtime must reject a legacy KV manager override");
1261
1262 assert!(error
1263 .to_string()
1264 .contains("cannot be combined with a legacy engine KV-cache override"));
1265 }
1266}