Skip to main content

runtime/
inference.rs

1//! Complete inference pipeline for text generation
2//!
3//! This module ties together tokenization, model inference, and text generation
4//! into a complete pipeline that can process text input and generate responses.
5
6use crate::types::*;
7use crate::models_v2::llama::{LlamaModelV2, LlamaConfig};
8use crate::model_core::{Model, GenerationConfig, ModelInputs, ModelConfig};
9use crate::tokenizer::Tokenizer;
10use crate::tensor_core::{Tensor, Device};
11
12// GenerationConfig is now imported from model_core
13
14/// Sampling strategy for text generation
15pub struct Sampler {
16    _tensor_ops: crate::tensor_core::CpuTensorOpsImpl,
17}
18
19impl Sampler {
20    pub fn new() -> Self {
21        Self {
22            _tensor_ops: crate::tensor_core::CpuTensorOpsImpl::new(),
23        }
24    }
25
26    /// Greedy sampling - always pick the highest probability token
27    pub fn sample_greedy(&self, logits: &[f32]) -> ModelResult<u32> {
28        if logits.is_empty() {
29            return Err(ModelError::ComputationFailed("Empty logits".to_string()));
30        }
31
32        let mut max_idx = 0;
33        let mut max_val = logits[0];
34
35        for (idx, &val) in logits.iter().enumerate() {
36            if val > max_val {
37                max_val = val;
38                max_idx = idx;
39            }
40        }
41
42        Ok(max_idx as u32)
43    }
44
45    /// Temperature sampling - sample from temperature-scaled probability distribution
46    pub fn sample_temperature(&self, logits: &[f32], temperature: f32) -> ModelResult<u32> {
47        if logits.is_empty() {
48            return Err(ModelError::ComputationFailed("Empty logits".to_string()));
49        }
50
51        // Scale logits by temperature
52        let scaled_logits: Vec<f32> = logits.iter().map(|&x| x / temperature).collect();
53
54        // Convert to probabilities using softmax
55        let probabilities = self.softmax(&scaled_logits)?;
56
57        // Sample from the distribution
58        self.sample_from_probabilities(&probabilities)
59    }
60
61    /// Top-p (nucleus) sampling
62    pub fn sample_top_p(&self, logits: &[f32], top_p: f32, temperature: f32) -> ModelResult<u32> {
63        if logits.is_empty() {
64            return Err(ModelError::ComputationFailed("Empty logits".to_string()));
65        }
66
67        // Scale by temperature
68        let scaled_logits: Vec<f32> = logits.iter().map(|&x| x / temperature).collect();
69
70        // Convert to probabilities
71        let probabilities = self.softmax(&scaled_logits)?;
72
73        // Sort indices by probability (descending)
74        let mut indexed_probs: Vec<(usize, f32)> = probabilities
75            .iter()
76            .enumerate()
77            .map(|(i, &p)| (i, p))
78            .collect();
79        indexed_probs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
80
81        // Find top-p cutoff
82        let mut cumulative_prob = 0.0;
83        let mut cutoff_idx = indexed_probs.len();
84
85        for (i, (_, prob)) in indexed_probs.iter().enumerate() {
86            cumulative_prob += prob;
87            if cumulative_prob >= top_p {
88                cutoff_idx = i + 1;
89                break;
90            }
91        }
92
93        // Create filtered distribution
94        let mut filtered_probs = vec![0.0; logits.len()];
95        let mut total_prob = 0.0;
96
97        for (idx, prob) in indexed_probs.iter().take(cutoff_idx) {
98            filtered_probs[*idx] = *prob;
99            total_prob += prob;
100        }
101
102        // Renormalize
103        if total_prob > 0.0 {
104            for prob in &mut filtered_probs {
105                *prob /= total_prob;
106            }
107        }
108
109        self.sample_from_probabilities(&filtered_probs)
110    }
111
112    fn softmax(&self, logits: &[f32]) -> ModelResult<Vec<f32>> {
113        if logits.is_empty() {
114            return Ok(Vec::new());
115        }
116
117        // Find max for numerical stability
118        let max_val = logits.iter().fold(f32::NEG_INFINITY, |a, &b| a.max(b));
119
120        // Compute exp(x - max) and sum
121        let mut exp_vals = Vec::with_capacity(logits.len());
122        let mut sum = 0.0;
123
124        for &logit in logits {
125            let exp_val = (logit - max_val).exp();
126            exp_vals.push(exp_val);
127            sum += exp_val;
128        }
129
130        // Normalize
131        for exp_val in &mut exp_vals {
132            *exp_val /= sum;
133        }
134
135        Ok(exp_vals)
136    }
137
138    fn sample_from_probabilities(&self, probabilities: &[f32]) -> ModelResult<u32> {
139        use rand::Rng;
140
141        // Sample using cumulative distribution with actual randomness
142        let mut rng = rand::thread_rng();
143        let random_val: f32 = rng.gen();
144        let mut cumulative = 0.0;
145
146        for (idx, &prob) in probabilities.iter().enumerate() {
147            cumulative += prob;
148            if random_val <= cumulative {
149                return Ok(idx as u32);
150            }
151        }
152
153        // Fallback to last token
154        Ok((probabilities.len() - 1) as u32)
155    }
156}
157
158/// Complete inference pipeline
159pub struct InferencePipeline {
160    model: LlamaModelV2,
161    tokenizer: Tokenizer,
162    sampler: Sampler,
163}
164
165impl InferencePipeline {
166    /// Create new inference pipeline
167    pub fn new(model: LlamaModelV2, tokenizer: Tokenizer) -> Self {
168        Self {
169            model,
170            tokenizer,
171            sampler: Sampler::new(),
172        }
173    }
174
175    /// Generate text from a prompt
176    pub fn generate(&self, prompt: &str, config: &GenerationConfig) -> ModelResult<String> {
177        // Tokenize input
178        let input_tokens = self.tokenizer.encode(prompt);
179
180        // Generate tokens
181        let generated_tokens = self.generate_tokens(&input_tokens, config)?;
182
183        // Decode output
184        let output_text = self.tokenizer.decode(&generated_tokens);
185
186        Ok(output_text)
187    }
188
189    /// Generate token sequence
190    fn generate_tokens(&self, input_tokens: &[u32], config: &GenerationConfig) -> ModelResult<Vec<u32>> {
191        let mut current_tokens = input_tokens.to_vec();
192        let mut generated_count = 0;
193
194        while generated_count < config.max_new_tokens {
195            // Create model inputs
196            let input_tensor = self.create_input_tensor(&current_tokens)?;
197            let inputs = ModelInputs::Text {
198                input_ids: input_tensor,
199                attention_mask: None,
200                position_ids: None
201            };
202
203            // Run model forward pass
204            let outputs = self.model.forward(&inputs).map_err(|e| ModelError::ComputationFailed(format!("Forward pass failed: {}", e)))?;
205
206            // Extract logits from model outputs
207            let logits = match outputs {
208                crate::model_core::ModelOutputs::Logits { logits, .. } => logits,
209                _ => return Err(ModelError::ComputationFailed("Expected logits output".to_string())),
210            };
211
212            // Get logits for the last token (simplified - assuming shape is [seq_len, vocab_size])
213            let last_token_logits = self.extract_last_token_logits(&logits)?;
214
215            // Sample next token
216            let next_token = if config.do_sample {
217                if config.top_p < 1.0 {
218                    self.sampler.sample_top_p(&last_token_logits, config.top_p, config.temperature)?
219                } else {
220                    self.sampler.sample_temperature(&last_token_logits, config.temperature)?
221                }
222            } else {
223                self.sampler.sample_greedy(&last_token_logits)?
224            };
225
226            // Check for EOS token
227            if next_token == config.eos_token_id {
228                break;
229            }
230
231            // Add token and continue
232            current_tokens.push(next_token);
233            generated_count += 1;
234        }
235
236        Ok(current_tokens)
237    }
238
239    /// Create input tensor from token sequence
240    fn create_input_tensor(&self, tokens: &[u32]) -> ModelResult<Tensor> {
241        // Convert tokens to i64 for the embedding layer
242        let tokens_i64: Vec<i64> = tokens.iter().map(|&t| t as i64).collect();
243        let shape = &[1, tokens.len()]; // batch_size=1, seq_len=tokens.len()
244
245        Tensor::from_i64_slice(&tokens_i64, shape, &Device::CPU)
246            .map_err(|e| ModelError::ComputationFailed(format!("Failed to create input tensor: {}", e)))
247    }
248
249    /// Extract logits for the last token from the logits tensor
250    fn extract_last_token_logits(&self, logits: &Tensor) -> ModelResult<Vec<f32>> {
251        // logits shape is typically [batch_size, seq_len, vocab_size]
252        // We need to extract [vocab_size] for the last token
253
254        let shape = logits.shape();
255        if shape.len() < 2 {
256            return Err(ModelError::ComputationFailed(
257                format!("Logits tensor has unexpected shape: {:?}", shape)
258            ));
259        }
260
261        // Try to convert to Candle tensor and extract last token logits
262        let candle_logits = logits.to_candle()
263            .map_err(|e| ModelError::ComputationFailed(format!("Failed to convert logits: {}", e)))?;
264
265        // Get logits for the last token position
266        let vocab_size = if shape.len() == 3 {
267            shape[2]
268        } else if shape.len() == 2 {
269            shape[1]
270        } else {
271            return Err(ModelError::ComputationFailed(
272                format!("Unexpected logits shape: {:?}", shape)
273            ));
274        };
275
276        // Extract the last token's logits
277        // For shape [batch, seq, vocab], we want [0, -1, :] -> [vocab]
278        let last_logits = if shape.len() == 3 {
279            let seq_len = shape[1];
280            candle_logits
281                .narrow(1, seq_len - 1, 1)
282                .map_err(|e| ModelError::ComputationFailed(format!("Failed to narrow: {}", e)))?
283                .squeeze(1)
284                .map_err(|e| ModelError::ComputationFailed(format!("Failed to squeeze: {}", e)))?
285                .squeeze(0)
286                .map_err(|e| ModelError::ComputationFailed(format!("Failed to squeeze batch: {}", e)))?
287        } else {
288            // shape [seq, vocab]
289            let seq_len = shape[0];
290            candle_logits
291                .narrow(0, seq_len - 1, 1)
292                .map_err(|e| ModelError::ComputationFailed(format!("Failed to narrow: {}", e)))?
293                .squeeze(0)
294                .map_err(|e| ModelError::ComputationFailed(format!("Failed to squeeze: {}", e)))?
295        };
296
297        // Convert to Vec<f32>
298        let logits_vec: Vec<f32> = last_logits
299            .to_vec1()
300            .map_err(|e| ModelError::ComputationFailed(format!("Failed to convert to vec: {}", e)))?;
301
302        if logits_vec.len() != vocab_size {
303            return Err(ModelError::ComputationFailed(
304                format!("Logits size mismatch: got {}, expected {}", logits_vec.len(), vocab_size)
305            ));
306        }
307
308        Ok(logits_vec)
309    }
310
311    /// Get model configuration
312    pub fn model_config(&self) -> &LlamaConfig {
313        self.model.config()
314    }
315
316    /// Get tokenizer reference
317    pub fn tokenizer(&self) -> &Tokenizer {
318        &self.tokenizer
319    }
320}
321
322/// Builder for creating inference pipelines
323pub struct InferencePipelineBuilder {
324    model_config: Option<LlamaConfig>,
325    tokenizer: Option<Tokenizer>,
326}
327
328impl InferencePipelineBuilder {
329    pub fn new() -> Self {
330        Self {
331            model_config: None,
332            tokenizer: None,
333        }
334    }
335
336    pub fn with_model_config(mut self, config: LlamaConfig) -> Self {
337        self.model_config = Some(config);
338        self
339    }
340
341    pub fn with_tokenizer(mut self, tokenizer: Tokenizer) -> Self {
342        self.tokenizer = Some(tokenizer);
343        self
344    }
345
346    pub fn build(self) -> ModelResult<InferencePipeline> {
347        let model_config = self.model_config.ok_or_else(|| {
348            ModelError::InitializationFailed("Model config not provided".to_string())
349        })?;
350
351        let tokenizer = self.tokenizer.unwrap_or_else(Tokenizer::new);
352
353        // Create model
354        let model = LlamaModelV2::new(model_config)
355            .map_err(|e| ModelError::InitializationFailed(format!("Failed to create model: {}", e)))?;
356
357        Ok(InferencePipeline::new(model, tokenizer))
358    }
359}
360
361/// Performance metrics for inference
362#[derive(Debug, Clone)]
363pub struct InferenceMetrics {
364    pub prompt_tokens: usize,
365    pub generated_tokens: usize,
366    pub total_tokens: usize,
367    pub inference_time_ms: f64,
368    pub tokens_per_second: f64,
369}
370
371impl InferenceMetrics {
372    pub fn new(prompt_tokens: usize, generated_tokens: usize, inference_time_ms: f64) -> Self {
373        let total_tokens = prompt_tokens + generated_tokens;
374        let tokens_per_second = if inference_time_ms > 0.0 {
375            (total_tokens as f64) / (inference_time_ms / 1000.0)
376        } else {
377            0.0
378        };
379
380        Self {
381            prompt_tokens,
382            generated_tokens,
383            total_tokens,
384            inference_time_ms,
385            tokens_per_second,
386        }
387    }
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393
394    #[test]
395    fn test_generation_config() {
396        let config = GenerationConfig::default();
397        assert_eq!(config.max_new_tokens, 100);
398        assert_eq!(config.temperature, 1.0);
399        assert_eq!(config.top_p, 0.9);
400        assert!(config.do_sample); // Default is true
401    }
402
403    #[test]
404    fn test_sampler_greedy() {
405        let sampler = Sampler::new();
406        let logits = vec![0.1, 0.8, 0.3, 0.5];
407
408        let token = sampler.sample_greedy(&logits).unwrap();
409        assert_eq!(token, 1); // Index of highest value (0.8)
410    }
411
412    #[test]
413    fn test_sampler_empty_logits() {
414        let sampler = Sampler::new();
415        let logits = vec![];
416
417        let result = sampler.sample_greedy(&logits);
418        assert!(result.is_err());
419    }
420
421    #[test]
422    fn test_sampler_temperature() {
423        let sampler = Sampler::new();
424        let logits = vec![1.0, 2.0, 1.5];
425
426        let token = sampler.sample_temperature(&logits, 1.0).unwrap();
427        assert!(token < 3); // Should be valid token index
428    }
429
430    #[test]
431    fn test_sampler_top_p() {
432        let sampler = Sampler::new();
433        let logits = vec![1.0, 3.0, 2.0, 0.5];
434
435        let token = sampler.sample_top_p(&logits, 0.8, 1.0).unwrap();
436        assert!(token < 4); // Should be valid token index
437    }
438
439    #[test]
440    fn test_softmax() {
441        let sampler = Sampler::new();
442        let logits = vec![1.0, 2.0, 3.0];
443
444        let probs = sampler.softmax(&logits).unwrap();
445        assert_eq!(probs.len(), 3);
446
447        // Probabilities should sum to 1
448        let sum: f32 = probs.iter().sum();
449        assert!((sum - 1.0).abs() < 1e-6);
450
451        // Should be in ascending order (since logits are ascending)
452        assert!(probs[0] < probs[1]);
453        assert!(probs[1] < probs[2]);
454    }
455
456    #[test]
457    fn test_inference_pipeline_builder() {
458        let config = LlamaConfig {
459            vocab_size: 1000,
460            hidden_size: 128,
461            num_hidden_layers: 2,
462            num_attention_heads: 8,
463            ..Default::default()
464        };
465
466        let tokenizer = Tokenizer::new();
467
468        let pipeline = InferencePipelineBuilder::new()
469            .with_model_config(config.clone())
470            .with_tokenizer(tokenizer)
471            .build()
472            .unwrap();
473
474        assert_eq!(pipeline.model_config().vocab_size(), config.vocab_size());
475        assert_eq!(pipeline.model_config().hidden_size(), config.hidden_size());
476    }
477
478    #[test]
479    fn test_inference_pipeline_generation() {
480        let config = LlamaConfig {
481            vocab_size: 1000,
482            hidden_size: 64,
483            num_hidden_layers: 1,
484            num_attention_heads: 4,
485            intermediate_size: 128,
486            max_position_embeddings: 64,
487            ..Default::default()
488        };
489
490        let pipeline = InferencePipelineBuilder::new()
491            .with_model_config(config)
492            .build()
493            .unwrap();
494
495        let gen_config = GenerationConfig {
496            max_new_tokens: 5,
497            temperature: 1.0,
498            do_sample: false, // Greedy
499            ..Default::default()
500        };
501
502        let result = pipeline.generate("hello world", &gen_config);
503        // With dummy zero tensors, we expect a computation error - this is normal
504        // In a real implementation with proper model weights, this would succeed
505        match result {
506            Ok(output) => {
507                assert!(!output.is_empty());
508                println!("Generated: {}", output);
509            }
510            Err(e) => {
511                // Expected error with dummy tensors
512                assert!(e.to_string().contains("matmul") || e.to_string().contains("Forward pass failed"));
513                println!("Expected error with dummy tensors: {}", e);
514            }
515        }
516    }
517
518    #[test]
519    fn test_inference_metrics() {
520        let metrics = InferenceMetrics::new(10, 20, 1000.0);
521
522        assert_eq!(metrics.prompt_tokens, 10);
523        assert_eq!(metrics.generated_tokens, 20);
524        assert_eq!(metrics.total_tokens, 30);
525        assert_eq!(metrics.inference_time_ms, 1000.0);
526        assert_eq!(metrics.tokens_per_second, 30.0);
527    }
528
529    #[test]
530    fn test_empty_prompt() {
531        let config = LlamaConfig {
532            vocab_size: 500,
533            hidden_size: 32,
534            num_hidden_layers: 1,
535            num_attention_heads: 2,
536            intermediate_size: 64,
537            max_position_embeddings: 32,
538            ..Default::default()
539        };
540
541        let pipeline = InferencePipelineBuilder::new()
542            .with_model_config(config)
543            .build()
544            .unwrap();
545
546        let gen_config = GenerationConfig {
547            max_new_tokens: 3,
548            ..Default::default()
549        };
550
551        let result = pipeline.generate("", &gen_config);
552        // With dummy zero tensors, we expect a computation error - this is normal
553        // In a real implementation with proper model weights, this would succeed
554        match result {
555            Ok(output) => {
556                println!("Generated from empty prompt: '{}'", output);
557            }
558            Err(e) => {
559                // Expected error with dummy tensors
560                assert!(e.to_string().contains("matmul") || e.to_string().contains("Forward pass failed"));
561                println!("Expected error with dummy tensors: {}", e);
562            }
563        }
564    }
565
566    #[test]
567    fn test_builder_missing_config() {
568        let result = InferencePipelineBuilder::new().build();
569        assert!(result.is_err());
570    }
571}