Coverage Report

Created: 2026-01-25 15:05

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/noah/src/realizar/src/gguf/inference/cached/sync.rs
Line
Count
Source
1
//! Thread-safe cached model wrapper (Mutex-based)
2
//!
3
//! `OwnedQuantizedModelCachedSync` uses Mutex for interior mutability,
4
//! suitable for async HTTP servers and multi-threaded inference.
5
6
use crate::error::{RealizarError, Result};
7
use crate::gguf::{
8
    BatchGenerationStats, DispatchMetrics, OwnedQuantizedKVCache,
9
    OwnedQuantizedModel, QuantizedGenerateConfig,
10
};
11
use super::weights::{DequantizedFFNWeights, DequantizedWeightCache};
12
13
/// Thread-safe cached model wrapper with Mutex-based scheduler caching
14
///
15
/// Uses `Mutex` for interior mutability to cache GPU schedulers. Safe for
16
/// multi-threaded HTTP serving with async handlers.
17
pub struct OwnedQuantizedModelCachedSync {
18
    /// Inner model (not cached)
19
    model: OwnedQuantizedModel,
20
    /// Cached HybridScheduler for GPU operations (wgpu backend)
21
    /// Uses Mutex for thread-safe interior mutability
22
    scheduler: std::sync::Mutex<Option<crate::gpu::HybridScheduler>>,
23
    /// PARITY-103: Cached CudaScheduler for direct CUDA operations
24
    /// Bypasses wgpu 256MB buffer limit by using cuBLAS directly
25
    #[cfg(feature = "cuda")]
26
    cuda_scheduler: std::sync::Mutex<Option<crate::gpu::CudaScheduler>>,
27
    /// Dequantized weight cache for GPU batch inference (PARITY-019)
28
    /// Uses RwLock for concurrent read access during batch inference
29
    dequant_cache: std::sync::RwLock<Option<DequantizedWeightCache>>,
30
}
31
32
// Explicitly implement Send + Sync for HTTP server usage
33
#[cfg(feature = "gpu")]
34
unsafe impl Send for OwnedQuantizedModelCachedSync {}
35
#[cfg(feature = "gpu")]
36
unsafe impl Sync for OwnedQuantizedModelCachedSync {}
37
38
#[cfg(feature = "gpu")]
39
impl OwnedQuantizedModelCachedSync {
40
    /// Create a new thread-safe cached model wrapper
41
    ///
42
    /// The scheduler is lazily initialized on first GPU operation.
43
    /// The dequantized weight cache is lazily initialized via `warmup_gpu_cache()`.
44
    /// PARITY-103: Also initializes CudaScheduler when CUDA feature is enabled.
45
    #[must_use]
46
39
    pub fn new(model: OwnedQuantizedModel) -> Self {
47
39
        Self {
48
39
            model,
49
39
            scheduler: std::sync::Mutex::new(None),
50
39
            #[cfg(feature = "cuda")]
51
39
            cuda_scheduler: std::sync::Mutex::new(None),
52
39
            dequant_cache: std::sync::RwLock::new(None),
53
39
        }
54
39
    }
55
56
    /// Get reference to inner model
57
    #[must_use]
58
2.64k
    pub fn model(&self) -> &OwnedQuantizedModel {
59
2.64k
        &self.model
60
2.64k
    }
61
62
    /// Get or create the cached scheduler (thread-safe)
63
    ///
64
    /// # Errors
65
    /// Returns error if scheduler creation fails or lock is poisoned
66
0
    fn get_scheduler(
67
0
        &self,
68
0
    ) -> Result<std::sync::MutexGuard<'_, Option<crate::gpu::HybridScheduler>>> {
69
0
        let mut scheduler_opt =
70
0
            self.scheduler
71
0
                .lock()
72
0
                .map_err(|_| RealizarError::UnsupportedOperation {
73
0
                    operation: "scheduler_lock".to_string(),
74
0
                    reason: "Scheduler mutex poisoned".to_string(),
75
0
                })?;
76
77
        // Initialize if not already done
78
0
        if scheduler_opt.is_none() {
79
            use crate::gpu::HybridScheduler;
80
0
            let new_scheduler = HybridScheduler::with_threshold(1000).map_err(|e| {
81
0
                RealizarError::UnsupportedOperation {
82
0
                    operation: "HybridScheduler::with_threshold".to_string(),
83
0
                    reason: format!("GPU scheduler initialization failed: {e}"),
84
0
                }
85
0
            })?;
86
0
            *scheduler_opt = Some(new_scheduler);
87
0
        }
88
89
0
        Ok(scheduler_opt)
90
0
    }
91
92
    /// PARITY-103: Get or create the cached CUDA scheduler (thread-safe)
93
    ///
94
    /// Bypasses wgpu 256MB buffer limit by using cuBLAS directly.
95
    /// Returns None if CUDA is not available.
96
    ///
97
    /// # Errors
98
    /// Returns error if lock is poisoned
99
    #[cfg(feature = "cuda")]
100
    fn get_cuda_scheduler(
101
        &self,
102
    ) -> Result<std::sync::MutexGuard<'_, Option<crate::gpu::CudaScheduler>>> {
103
        use crate::gpu::CudaScheduler;
104
105
        let mut scheduler_opt =
106
            self.cuda_scheduler
107
                .lock()
108
                .map_err(|_| RealizarError::UnsupportedOperation {
109
                    operation: "cuda_scheduler_lock".to_string(),
110
                    reason: "CUDA scheduler mutex poisoned".to_string(),
111
                })?;
112
113
        // Initialize if not already done
114
        if scheduler_opt.is_none() {
115
            match CudaScheduler::new() {
116
                Ok(new_scheduler) => {
117
                    eprintln!("PARITY-103: CudaScheduler initialized successfully");
118
                    *scheduler_opt = Some(new_scheduler);
119
                },
120
                Err(e) => {
121
                    // CUDA not available, leave as None (will fallback to wgpu)
122
                    eprintln!("PARITY-103: CudaScheduler::new() failed: {:?}", e);
123
                },
124
            }
125
        }
126
127
        Ok(scheduler_opt)
128
    }
129
130
    /// PARITY-103: Batch matmul preferring CUDA over wgpu (thread-safe)
131
    ///
132
    /// Tries CudaScheduler first (no buffer limits), falls back to HybridScheduler (wgpu).
133
    /// This bypasses the wgpu 256MB buffer limit that was blocking GPU batch inference.
134
    #[cfg(feature = "cuda")]
135
    fn batch_matmul_gpu_prefer_cuda(
136
        &self,
137
        input: &[f32],
138
        weight_f32: &[f32],
139
        batch_size: usize,
140
        in_dim: usize,
141
        out_dim: usize,
142
    ) -> Result<Vec<f32>> {
143
        // Validate input
144
        if input.len() != batch_size * in_dim {
145
            return Err(RealizarError::InvalidShape {
146
                reason: format!(
147
                    "Input size {} doesn't match batch_size={} * in_dim={}",
148
                    input.len(),
149
                    batch_size,
150
                    in_dim
151
                ),
152
            });
153
        }
154
155
        // Try CUDA first (no buffer size limits)
156
        if let Ok(mut cuda_guard) = self.get_cuda_scheduler() {
157
            if let Some(ref mut cuda_sched) = *cuda_guard {
158
                return cuda_sched
159
                    .matmul(input, weight_f32, batch_size, in_dim, out_dim)
160
                    .map_err(|e| RealizarError::UnsupportedOperation {
161
                        operation: "batch_matmul_gpu_prefer_cuda".to_string(),
162
                        reason: format!("CUDA matmul failed: {e}"),
163
                    });
164
            }
165
        }
166
167
        // Fallback to wgpu (may hit 256MB limit for large batches)
168
        let mut scheduler_guard = self.get_scheduler()?;
169
        if let Some(ref mut scheduler) = *scheduler_guard {
170
            return scheduler
171
                .matmul(input, weight_f32, batch_size, in_dim, out_dim)
172
                .map_err(|e| RealizarError::UnsupportedOperation {
173
                    operation: "batch_matmul_gpu_prefer_cuda".to_string(),
174
                    reason: format!("GPU matmul failed: {e}"),
175
                });
176
        }
177
178
        Err(RealizarError::UnsupportedOperation {
179
            operation: "batch_matmul_gpu_prefer_cuda".to_string(),
180
            reason: "No GPU scheduler available".to_string(),
181
        })
182
    }
183
184
    /// PARITY-103: Batch matmul preferring CUDA (non-CUDA fallback)
185
    #[cfg(not(feature = "cuda"))]
186
0
    fn batch_matmul_gpu_prefer_cuda(
187
0
        &self,
188
0
        input: &[f32],
189
0
        weight_f32: &[f32],
190
0
        batch_size: usize,
191
0
        in_dim: usize,
192
0
        out_dim: usize,
193
0
    ) -> Result<Vec<f32>> {
194
        // Validate input
195
0
        if input.len() != batch_size * in_dim {
196
0
            return Err(RealizarError::InvalidShape {
197
0
                reason: format!(
198
0
                    "Input size {} doesn't match batch_size={} * in_dim={}",
199
0
                    input.len(),
200
0
                    batch_size,
201
0
                    in_dim
202
0
                ),
203
0
            });
204
0
        }
205
206
0
        let mut scheduler_guard = self.get_scheduler()?;
207
0
        if let Some(ref mut scheduler) = *scheduler_guard {
208
0
            return scheduler
209
0
                .matmul(input, weight_f32, batch_size, in_dim, out_dim)
210
0
                .map_err(|e| RealizarError::UnsupportedOperation {
211
0
                    operation: "batch_matmul_gpu_prefer_cuda".to_string(),
212
0
                    reason: format!("GPU matmul failed: {e}"),
213
0
                });
214
0
        }
215
216
0
        Err(RealizarError::UnsupportedOperation {
217
0
            operation: "batch_matmul_gpu_prefer_cuda".to_string(),
218
0
            reason: "No GPU scheduler available".to_string(),
219
0
        })
220
0
    }
221
222
    /// Generate tokens with KV cache using thread-safe cached scheduler
223
    ///
224
    /// Delegates to the inner model's `generate_with_cache` method.
225
    /// The scheduler caching benefits GPU batch operations; single-token
226
    /// generation uses CPU path with KV cache for O(n) scaling.
227
    ///
228
    /// # Arguments
229
    /// * `prompt` - Input token IDs
230
    /// * `config` - Generation configuration
231
    ///
232
    /// # Returns
233
    /// Generated token sequence including prompt
234
    ///
235
    /// # Errors
236
    /// Returns error if generation fails
237
0
    pub fn generate_with_cache(
238
0
        &self,
239
0
        prompt: &[u32],
240
0
        config: &QuantizedGenerateConfig,
241
0
    ) -> Result<Vec<u32>> {
242
        // Delegate to inner model - CPU path with KV cache is already efficient
243
0
        self.model.generate_with_cache(prompt, config)
244
0
    }
245
246
    /// Generate tokens with adaptive CPU/GPU attention (IMP-126)
247
    ///
248
    /// This variant of `generate_with_cache` uses adaptive CPU/GPU dispatch
249
    /// based on cache length and records dispatch decisions to metrics.
250
    ///
251
    /// # Arguments
252
    /// * `prompt` - Initial token IDs
253
    /// * `config` - Generation configuration
254
    /// * `metrics` - Dispatch metrics tracker for CPU/GPU decision recording
255
    ///
256
    /// # Returns
257
    /// Generated token sequence including prompt
258
    ///
259
    /// # Errors
260
    /// Returns error if generation fails
261
    #[cfg(feature = "gpu")]
262
5
    pub fn generate_with_cache_adaptive(
263
5
        &self,
264
5
        prompt: &[u32],
265
5
        config: &QuantizedGenerateConfig,
266
5
        metrics: &std::sync::Arc<DispatchMetrics>,
267
5
    ) -> Result<Vec<u32>> {
268
        // Delegate to inner model's adaptive generation
269
5
        self.model
270
5
            .generate_with_cache_adaptive(prompt, config, metrics)
271
5
    }
272
273
    /// Forward pass with cached scheduler (thread-safe)
274
    ///
275
    /// Uses the cached HybridScheduler for GPU operations.
276
    ///
277
    /// # Errors
278
    /// Returns error if GPU operations fail
279
    #[allow(clippy::let_underscore_untyped)] // Placeholder for future use
280
0
    pub fn forward_batch_gpu_cached(&self, token_ids: &[u32]) -> Result<Vec<f32>> {
281
0
        let batch_size = token_ids.len();
282
0
        let vocab_size = self.model.config.vocab_size;
283
284
        // Get cached scheduler (for future GPU operations)
285
0
        let mut scheduler_guard = self.get_scheduler()?;
286
0
        let _ = scheduler_guard
287
0
            .as_mut()
288
0
            .ok_or_else(|| RealizarError::UnsupportedOperation {
289
0
                operation: "forward_batch_gpu_cached".to_string(),
290
0
                reason: "Scheduler not initialized".to_string(),
291
0
            })?;
292
293
        // 1. Token embedding lookup
294
0
        let hidden = self.model.embed(token_ids);
295
296
        // 2. Process through layers
297
0
        for layer in &self.model.layers {
298
0
            // Simplified single-layer forward - reuse inner model logic
299
0
            // For full implementation, would need to port the complete forward pass
300
0
            let _ = layer;
301
0
        }
302
303
        // 3. Output normalization and LM head
304
        // For now, return placeholder - full implementation requires porting forward logic
305
0
        let output = vec![0.0f32; batch_size * vocab_size];
306
0
        let _ = hidden;
307
308
0
        Ok(output)
309
0
    }
310
311
    /// Adaptive fused attention for production serving (IMP-121)
312
    ///
313
    /// Thread-safe wrapper that automatically selects CPU or GPU based on
314
    /// sequence length. Uses the cached scheduler for efficient GPU operations.
315
    ///
316
    /// # Arguments
317
    /// * `q` - Query tensor [seq_len, head_dim]
318
    /// * `k` - Key tensor [seq_len, head_dim]
319
    /// * `v` - Value tensor [seq_len, head_dim]
320
    /// * `seq_len` - Sequence length
321
    /// * `head_dim` - Head dimension
322
    /// * `scale` - Attention scale factor
323
    ///
324
    /// # Returns
325
    /// Output tensor [seq_len, head_dim]
326
9
    pub fn adaptive_fused_attention(
327
9
        &self,
328
9
        q: &[f32],
329
9
        k: &[f32],
330
9
        v: &[f32],
331
9
        seq_len: usize,
332
9
        head_dim: usize,
333
9
        scale: f32,
334
9
    ) -> Result<Vec<f32>> {
335
        // Threshold for GPU dispatch (from IMP-119 analysis)
336
        const GPU_SEQ_LEN_THRESHOLD: usize = 64;
337
338
9
        if seq_len >= GPU_SEQ_LEN_THRESHOLD {
339
            // Long sequence: Use GPU path
340
4
            self.gpu_fused_causal_attention(q, k, v, seq_len, head_dim, scale)
341
        } else {
342
            // Short sequence: Use CPU path
343
5
            self.cpu_fused_causal_attention(q, k, v, seq_len, head_dim, scale)
344
        }
345
9
    }
346
347
    /// CPU fused causal attention (thread-safe wrapper)
348
5
    fn cpu_fused_causal_attention(
349
5
        &self,
350
5
        q: &[f32],
351
5
        k: &[f32],
352
5
        v: &[f32],
353
5
        seq_len: usize,
354
5
        head_dim: usize,
355
5
        scale: f32,
356
5
    ) -> Result<Vec<f32>> {
357
        // Use tiled implementation from inner model
358
5
        self.model
359
5
            .tiled_causal_attention(q, k, v, seq_len, head_dim, scale, 4)
360
5
    }
361
362
    /// GPU fused causal attention (thread-safe)
363
4
    fn gpu_fused_causal_attention(
364
4
        &self,
365
4
        q: &[f32],
366
4
        k: &[f32],
367
4
        v: &[f32],
368
4
        seq_len: usize,
369
4
        head_dim: usize,
370
4
        scale: f32,
371
4
    ) -> Result<Vec<f32>> {
372
4
        let mut scheduler_guard =
373
4
            self.scheduler
374
4
                .lock()
375
4
                .map_err(|_| RealizarError::UnsupportedOperation {
376
0
                    operation: "gpu_fused_causal_attention".to_string(),
377
0
                    reason: "Failed to acquire scheduler lock".to_string(),
378
0
                })?;
379
380
        // Initialize scheduler if needed
381
4
        if scheduler_guard.is_none() {
382
            use crate::gpu::HybridScheduler;
383
1
            let new_scheduler = HybridScheduler::with_threshold(1000).map_err(|e| 
{0
384
0
                RealizarError::UnsupportedOperation {
385
0
                    operation: "HybridScheduler::with_threshold".to_string(),
386
0
                    reason: format!("GPU scheduler initialization failed: {e}"),
387
0
                }
388
0
            })?;
389
1
            *scheduler_guard = Some(new_scheduler);
390
3
        }
391
392
4
        let scheduler =
393
4
            scheduler_guard
394
4
                .as_mut()
395
4
                .ok_or_else(|| RealizarError::UnsupportedOperation {
396
0
                    operation: "gpu_fused_causal_attention".to_string(),
397
0
                    reason: "Scheduler not initialized".to_string(),
398
0
                })?;
399
400
        // Transpose K for matmul
401
4
        let mut k_transposed = vec![0.0f32; head_dim * seq_len];
402
256
        for pos in 0..
seq_len4
{
403
4.09k
            for d in 0..
head_dim256
{
404
4.09k
                k_transposed[d * seq_len + pos] = k[pos * head_dim + d];
405
4.09k
            }
406
        }
407
408
        // GPU Q @ K^T
409
4
        let scores = scheduler
410
4
            .matmul(q, &k_transposed, seq_len, head_dim, seq_len)
411
4
            .map_err(|e| RealizarError::UnsupportedOperation {
412
0
                operation: "gpu_fused Q@K^T".to_string(),
413
0
                reason: format!("GPU matmul failed: {}", e),
414
0
            })?;
415
416
        // CPU causal softmax
417
4
        let mut weights = vec![0.0f32; seq_len * seq_len];
418
256
        for i in 0..
seq_len4
{
419
256
            let mut max_val = f32::NEG_INFINITY;
420
8.32k
            for j in 0..=
i256
{
421
8.32k
                let score = scores[i * seq_len + j] * scale;
422
8.32k
                if score > max_val {
423
1.09k
                    max_val = score;
424
7.22k
                }
425
            }
426
256
            let mut sum = 0.0f32;
427
8.32k
            for j in 0..=
i256
{
428
8.32k
                let score = scores[i * seq_len + j] * scale;
429
8.32k
                weights[i * seq_len + j] = (score - max_val).exp();
430
8.32k
                sum += weights[i * seq_len + j];
431
8.32k
            }
432
256
            if sum > 0.0 {
433
8.32k
                for j in 0..=
i256
{
434
8.32k
                    weights[i * seq_len + j] /= sum;
435
8.32k
                }
436
0
            }
437
        }
438
439
        // GPU weights @ V
440
4
        scheduler
441
4
            .matmul(&weights, v, seq_len, seq_len, head_dim)
442
4
            .map_err(|e| RealizarError::UnsupportedOperation {
443
0
                operation: "gpu_fused weights@V".to_string(),
444
0
                reason: format!("GPU matmul failed: {}", e),
445
0
            })
446
4
    }
447
448
    /// Adaptive multihead attention for production serving (IMP-121)
449
    ///
450
    /// Thread-safe multi-head attention that automatically selects backend.
451
    ///
452
    /// # Arguments
453
    /// * `q` - Query tensor [seq_len, hidden_dim]
454
    /// * `k` - Key tensor [seq_len, hidden_dim]
455
    /// * `v` - Value tensor [seq_len, hidden_dim]
456
    /// * `seq_len` - Sequence length
457
    ///
458
    /// # Returns
459
    /// Output tensor [seq_len, hidden_dim]
460
1
    pub fn adaptive_multihead_attention(
461
1
        &self,
462
1
        q: &[f32],
463
1
        k: &[f32],
464
1
        v: &[f32],
465
1
        seq_len: usize,
466
1
    ) -> Result<Vec<f32>> {
467
1
        let hidden_dim = self.model.config.hidden_dim;
468
1
        let num_heads = self.model.config.num_heads;
469
1
        let head_dim = hidden_dim / num_heads;
470
1
        let scale = 1.0 / (head_dim as f32).sqrt();
471
472
        // Reshape Q, K, V to [num_heads, seq_len, head_dim]
473
1
        let q_reshaped = self
474
1
            .model
475
1
            .reshape_for_parallel_heads(q, seq_len, num_heads, head_dim)
?0
;
476
1
        let k_reshaped = self
477
1
            .model
478
1
            .reshape_for_parallel_heads(k, seq_len, num_heads, head_dim)
?0
;
479
1
        let v_reshaped = self
480
1
            .model
481
1
            .reshape_for_parallel_heads(v, seq_len, num_heads, head_dim)
?0
;
482
483
1
        let mut attn_output = vec![0.0f32; num_heads * seq_len * head_dim];
484
485
4
        for h in 0..
num_heads1
{
486
4
            let head_offset = h * seq_len * head_dim;
487
4
            let q_head = &q_reshaped[head_offset..head_offset + seq_len * head_dim];
488
4
            let k_head = &k_reshaped[head_offset..head_offset + seq_len * head_dim];
489
4
            let v_head = &v_reshaped[head_offset..head_offset + seq_len * head_dim];
490
491
4
            let head_output =
492
4
                self.adaptive_fused_attention(q_head, k_head, v_head, seq_len, head_dim, scale)
?0
;
493
494
4
            attn_output[head_offset..head_offset + seq_len * head_dim]
495
4
                .copy_from_slice(&head_output);
496
        }
497
498
        // Reshape back to [seq_len, hidden_dim]
499
1
        let mut output = vec![0.0f32; seq_len * hidden_dim];
500
4
        for h in 0..
num_heads1
{
501
4
            let head_start = h * seq_len * head_dim;
502
256
            for pos in 0..
seq_len4
{
503
256
                let src_start = head_start + pos * head_dim;
504
256
                let dst_start = pos * hidden_dim + h * head_dim;
505
256
                output[dst_start..dst_start + head_dim]
506
256
                    .copy_from_slice(&attn_output[src_start..src_start + head_dim]);
507
256
            }
508
        }
509
510
1
        Ok(output)
511
1
    }
512
513
    /// Warmup GPU weight cache for batch inference (PARITY-019)
514
    ///
515
    /// Pre-dequantizes all FFN weights to f32 for GPU GEMM operations.
516
    /// Call this once at server startup to avoid dequantization during inference.
517
    ///
518
    /// # Memory Usage
519
    /// - phi-2 (32 layers): ~6.4 GB
520
    /// - Per layer: 2 × hidden_dim × intermediate_dim × 4 bytes
521
    ///
522
    /// # Returns
523
    /// - Total memory allocated in bytes
524
    /// - Number of layers cached
525
    ///
526
    /// # Errors
527
    /// Returns error if dequantization fails
528
0
    pub fn warmup_gpu_cache(&self) -> Result<(usize, usize)> {
529
0
        let config = &self.model.config;
530
0
        let hidden_dim = config.hidden_dim;
531
0
        let intermediate_dim = config.intermediate_dim;
532
0
        let num_layers = self.model.layers.len();
533
534
        // Create cache with model dimensions
535
0
        let cache = DequantizedWeightCache::new(hidden_dim, intermediate_dim, num_layers);
536
537
        // Dequantize each layer's FFN weights
538
        // Note: warmup closure can't return Result, so we use unwrap_or_default
539
        // for robustness. In production, use warmup_gpu_cache_checked() for error handling.
540
0
        cache.warmup(|layer_idx| {
541
0
            let layer = &self.model.layers[layer_idx];
542
543
            // Dequantize using model's dequantize_weight method
544
0
            let up = self
545
0
                .model
546
0
                .dequantize_weight(&layer.ffn_up_weight)
547
0
                .unwrap_or_default();
548
0
            let down = self
549
0
                .model
550
0
                .dequantize_weight(&layer.ffn_down_weight)
551
0
                .unwrap_or_default();
552
553
0
            (up, down)
554
0
        });
555
556
0
        let memory_bytes = cache.memory_bytes();
557
0
        let cached_count = cache.cached_count();
558
559
        // Store in the cache field
560
0
        let mut cache_guard =
561
0
            self.dequant_cache
562
0
                .write()
563
0
                .map_err(|_| RealizarError::UnsupportedOperation {
564
0
                    operation: "warmup_gpu_cache".to_string(),
565
0
                    reason: "Cache lock poisoned".to_string(),
566
0
                })?;
567
0
        *cache_guard = Some(cache);
568
569
0
        Ok((memory_bytes, cached_count))
570
0
    }
571
572
    /// Check if GPU cache is warmed up
573
0
    pub fn is_gpu_cache_warm(&self) -> bool {
574
0
        self.dequant_cache
575
0
            .read()
576
0
            .map(|guard| guard.is_some())
577
0
            .unwrap_or(false)
578
0
    }
579
580
    /// Get GPU cache memory usage in bytes
581
0
    pub fn gpu_cache_memory(&self) -> usize {
582
0
        self.dequant_cache
583
0
            .read()
584
0
            .ok()
585
0
            .and_then(|guard| guard.as_ref().map(DequantizedWeightCache::memory_bytes))
586
0
            .unwrap_or(0)
587
0
    }
588
589
    /// Get dequantized weights for a layer (for GPU batch FFN)
590
    ///
591
    /// Returns None if cache not warmed up or layer not found.
592
0
    pub fn get_dequantized_ffn_weights(&self, layer_idx: usize) -> Option<DequantizedFFNWeights> {
593
0
        self.dequant_cache
594
0
            .read()
595
0
            .ok()
596
0
            .and_then(|guard| guard.as_ref().and_then(|c| c.get(layer_idx)))
597
0
    }
598
599
    /// Batch FFN forward pass using GPU (PARITY-019)
600
    ///
601
    /// Processes multiple tokens in parallel using GPU GEMM.
602
    /// Requires cache to be warmed up via `warmup_gpu_cache()`.
603
    ///
604
    /// # Arguments
605
    /// * `hidden_states` - Input tensor [batch_size × hidden_dim]
606
    /// * `layer_idx` - Layer index for weight lookup
607
    ///
608
    /// # Returns
609
    /// Output tensor [batch_size × hidden_dim]
610
    ///
611
    /// # Errors
612
    /// Returns error if cache not warmed or GPU operations fail
613
    /// PARITY-103: Batch FFN using CUDA when available
614
    ///
615
    /// Uses CudaScheduler first (no buffer limits), falls back to HybridScheduler (wgpu).
616
    /// This bypasses the wgpu 256MB buffer limit that was blocking GPU batch inference.
617
0
    pub fn batch_ffn_gpu(&self, hidden_states: &[f32], layer_idx: usize) -> Result<Vec<f32>> {
618
0
        let config = &self.model.config;
619
0
        let hidden_dim = config.hidden_dim;
620
0
        let intermediate_dim = config.intermediate_dim;
621
0
        let batch_size = hidden_states.len() / hidden_dim;
622
623
0
        if batch_size == 0 {
624
0
            return Err(RealizarError::UnsupportedOperation {
625
0
                operation: "batch_ffn_gpu".to_string(),
626
0
                reason: "Empty batch".to_string(),
627
0
            });
628
0
        }
629
630
        // Get cached weights
631
0
        let weights = self.get_dequantized_ffn_weights(layer_idx).ok_or_else(|| {
632
0
            RealizarError::UnsupportedOperation {
633
0
                operation: "batch_ffn_gpu".to_string(),
634
0
                reason: format!(
635
0
                    "Layer {} not cached. Call warmup_gpu_cache() first.",
636
0
                    layer_idx
637
0
                ),
638
0
            }
639
0
        })?;
640
641
        // PARITY-103: Up projection preferring CUDA
642
0
        let mut intermediate = self.batch_matmul_gpu_prefer_cuda(
643
0
            hidden_states,
644
0
            &weights.up,
645
0
            batch_size,
646
0
            hidden_dim,
647
0
            intermediate_dim,
648
0
        )?;
649
650
        // Add up bias if present
651
0
        if let Some(ref bias) = weights.up_bias {
652
0
            for b in 0..batch_size {
653
0
                for i in 0..intermediate_dim {
654
0
                    intermediate[b * intermediate_dim + i] += bias[i];
655
0
                }
656
            }
657
0
        }
658
659
        // GELU activation (CPU - fused in future)
660
0
        for x in &mut intermediate {
661
0
            let x64 = *x as f64;
662
0
            *x = (x64
663
0
                * 0.5
664
0
                * (1.0 + (x64 * 0.797_884_560_8 * (1.0 + 0.044_715 * x64 * x64)).tanh()))
665
0
                as f32;
666
0
        }
667
668
        // PARITY-103: Down projection preferring CUDA
669
0
        let mut output = self.batch_matmul_gpu_prefer_cuda(
670
0
            &intermediate,
671
0
            &weights.down,
672
0
            batch_size,
673
0
            intermediate_dim,
674
0
            hidden_dim,
675
0
        )?;
676
677
        // Add down bias if present
678
0
        if let Some(ref bias) = weights.down_bias {
679
0
            for b in 0..batch_size {
680
0
                for i in 0..hidden_dim {
681
0
                    output[b * hidden_dim + i] += bias[i];
682
0
                }
683
            }
684
0
        }
685
686
0
        Ok(output)
687
0
    }
688
689
    /// PARITY-103: Batch QKV projection using CUDA when available
690
    ///
691
    /// Projects hidden states to Q, K, V for all requests in batch.
692
    /// [batch, hidden] @ [hidden, 3*hidden] = [batch, 3*hidden]
693
    ///
694
    /// Uses CudaScheduler first (no buffer limits), falls back to HybridScheduler (wgpu).
695
    ///
696
    /// # Arguments
697
    /// * `hidden_states` - Flattened hidden states [batch * hidden_dim]
698
    /// * `layer_idx` - Layer index for weight lookup
699
    ///
700
    /// # Returns
701
    /// Flattened QKV projections [batch * 3 * hidden_dim]
702
    #[cfg(feature = "gpu")]
703
0
    pub fn batch_qkv_projection_gpu(
704
0
        &self,
705
0
        hidden_states: &[f32],
706
0
        layer_idx: usize,
707
0
    ) -> Result<Vec<f32>> {
708
0
        let hidden_dim = self.model.config.hidden_dim;
709
0
        let batch_size = hidden_states.len() / hidden_dim;
710
0
        let qkv_dim = 3 * hidden_dim;
711
712
0
        if batch_size == 0 {
713
0
            return Ok(Vec::new());
714
0
        }
715
716
0
        let layer = &self.model.layers[layer_idx];
717
718
        // Dequantize QKV weight for GPU GEMM
719
0
        let qkv_weight = self.model.dequantize_qkv(&layer.qkv_weight)?;
720
721
        // PARITY-103: QKV projection preferring CUDA
722
0
        let mut qkv = self.batch_matmul_gpu_prefer_cuda(
723
0
            hidden_states,
724
0
            &qkv_weight,
725
0
            batch_size,
726
0
            hidden_dim,
727
0
            qkv_dim,
728
0
        )?;
729
730
        // Add bias if present
731
0
        if let Some(ref bias) = layer.qkv_bias {
732
0
            for b in 0..batch_size {
733
0
                for i in 0..qkv_dim {
734
0
                    qkv[b * qkv_dim + i] += bias[i];
735
0
                }
736
            }
737
0
        }
738
739
0
        Ok(qkv)
740
0
    }
741
742
    /// Batch attention output projection using GPU GEMM (PARITY-024)
743
    ///
744
    /// Projects attention outputs for all requests in batch.
745
    /// [batch, hidden] @ [hidden, hidden] = [batch, hidden]
746
    ///
747
    /// # Arguments
748
    /// * `attention_outputs` - Flattened attention outputs [batch * hidden_dim]
749
    /// * `layer_idx` - Layer index for weight lookup
750
    ///
751
    /// # Returns
752
    /// Flattened projected outputs [batch * hidden_dim]
753
    #[cfg(feature = "gpu")]
754
0
    pub fn batch_attention_output_gpu(
755
0
        &self,
756
0
        attention_outputs: &[f32],
757
0
        layer_idx: usize,
758
0
    ) -> Result<Vec<f32>> {
759
0
        let hidden_dim = self.model.config.hidden_dim;
760
0
        let batch_size = attention_outputs.len() / hidden_dim;
761
762
0
        if batch_size == 0 {
763
0
            return Ok(Vec::new());
764
0
        }
765
766
0
        let layer = &self.model.layers[layer_idx];
767
768
        // Dequantize output weight for GPU GEMM
769
0
        let output_weight = self.model.dequantize_weight(&layer.attn_output_weight)?;
770
771
        // PARITY-103: Output projection preferring CUDA (bypasses wgpu 256MB limit)
772
        // [batch, hidden] @ [hidden, hidden] = [batch, hidden]
773
0
        let mut output = self.batch_matmul_gpu_prefer_cuda(
774
0
            attention_outputs,
775
0
            &output_weight,
776
0
            batch_size,
777
0
            hidden_dim,
778
0
            hidden_dim,
779
0
        )?;
780
781
        // Add bias if present
782
0
        if let Some(ref bias) = layer.attn_output_bias {
783
0
            for b in 0..batch_size {
784
0
                for i in 0..hidden_dim {
785
0
                    output[b * hidden_dim + i] += bias[i];
786
0
                }
787
            }
788
0
        }
789
790
0
        Ok(output)
791
0
    }
792
793
    /// Batch LM head projection using GPU GEMM (PARITY-025)
794
    ///
795
    /// Projects hidden states to vocabulary logits for all requests in batch.
796
    /// [batch, hidden] @ [hidden, vocab] = [batch, vocab]
797
    ///
798
    /// # Arguments
799
    /// * `hidden_states` - Flattened normalized hidden states [batch * hidden_dim]
800
    ///
801
    /// # Returns
802
    /// Flattened logits [batch * vocab_size]
803
    #[cfg(feature = "gpu")]
804
0
    pub fn batch_lm_head_gpu(&self, hidden_states: &[f32]) -> Result<Vec<f32>> {
805
0
        let hidden_dim = self.model.config.hidden_dim;
806
0
        let vocab_size = self.model.config.vocab_size;
807
0
        let batch_size = hidden_states.len() / hidden_dim;
808
809
0
        if batch_size == 0 {
810
0
            return Ok(Vec::new());
811
0
        }
812
813
        // Dequantize LM head weight for GPU GEMM
814
0
        let lm_head_weight = self.model.dequantize_weight(&self.model.lm_head_weight)?;
815
816
        // PARITY-103: LM head projection preferring CUDA (bypasses wgpu 256MB limit)
817
        // [batch, hidden] @ [hidden, vocab] = [batch, vocab]
818
0
        let mut logits = self.batch_matmul_gpu_prefer_cuda(
819
0
            hidden_states,
820
0
            &lm_head_weight,
821
0
            batch_size,
822
0
            hidden_dim,
823
0
            vocab_size,
824
0
        )?;
825
826
        // Add bias if present
827
0
        if let Some(ref bias) = self.model.lm_head_bias {
828
0
            for b in 0..batch_size {
829
0
                for i in 0..vocab_size {
830
0
                    logits[b * vocab_size + i] += bias[i];
831
0
                }
832
            }
833
0
        }
834
835
0
        Ok(logits)
836
0
    }
837
838
    /// Batch generation with GPU-accelerated FFN (PARITY-020)
839
    ///
840
    /// Processes multiple prompts in parallel using GPU batch operations.
841
    /// The key optimization is converting MATVEC (single token) to GEMM (batch tokens).
842
    ///
843
    /// # Architecture
844
    /// - Attention: CPU with KV cache (MATVEC is faster on CPU)
845
    /// - FFN: GPU with batch GEMM (batch_size ≥ 32 uses GPU)
846
    /// - Sampling: CPU (negligible compared to matmul)
847
    ///
848
    /// # Arguments
849
    /// * `prompts` - Multiple prompts to process in parallel [num_prompts][seq_len]
850
    /// * `config` - Generation configuration (shared across all prompts)
851
    ///
852
    /// # Returns
853
    /// Generated sequences for each prompt [num_prompts][generated_len]
854
    ///
855
    /// # Errors
856
    /// Returns error if GPU cache not warmed up or generation fails
857
    ///
858
    /// # Performance
859
    /// - Single prompt: ~5 tok/s (CPU-bound, no batching benefit)
860
    /// - 32 prompts: ~150 tok/s total (~4.7 tok/s per prompt)
861
    /// - 64 prompts: ~280 tok/s total (~4.4 tok/s per prompt, memory-bound)
862
0
    pub fn batch_generate_gpu(
863
0
        &self,
864
0
        prompts: &[Vec<u32>],
865
0
        config: &QuantizedGenerateConfig,
866
0
    ) -> Result<Vec<Vec<u32>>> {
867
0
        if prompts.is_empty() {
868
0
            return Ok(Vec::new());
869
0
        }
870
871
        // Verify GPU cache is warmed up
872
0
        if !self.is_gpu_cache_warm() {
873
0
            return Err(RealizarError::UnsupportedOperation {
874
0
                operation: "batch_generate_gpu".to_string(),
875
0
                reason: "GPU cache not warmed up. Call warmup_gpu_cache() first.".to_string(),
876
0
            });
877
0
        }
878
879
0
        let num_prompts = prompts.len();
880
0
        let max_seq_len = prompts.iter().map(Vec::len).max().unwrap_or(0) + config.max_tokens;
881
882
        // Initialize KV caches for each prompt
883
0
        let mut caches: Vec<OwnedQuantizedKVCache> = prompts
884
0
            .iter()
885
0
            .map(|_| OwnedQuantizedKVCache::from_config(&self.model.config, max_seq_len))
886
0
            .collect();
887
888
        // Initialize token sequences (copy prompts)
889
0
        let mut sequences: Vec<Vec<u32>> = prompts.to_vec();
890
891
        // Track generation progress per prompt
892
0
        let mut done: Vec<bool> = vec![false; num_prompts];
893
894
        // PARITY-097: Parallel prefill across prompts using rayon
895
        // Each prompt's prefill is independent (different KV cache)
896
        // Model is shared immutably (&self), caches are mutated independently
897
        use rayon::prelude::*;
898
899
0
        caches
900
0
            .par_iter_mut()
901
0
            .zip(prompts.par_iter())
902
0
            .try_for_each(|(cache, prompt)| {
903
0
                for (pos, &token_id) in prompt.iter().enumerate() {
904
0
                    self.model.forward_single_with_cache(token_id, cache, pos)?;
905
                }
906
0
                Ok::<_, RealizarError>(())
907
0
            })?;
908
909
        // Generation loop with batched FFN (PARITY-021: GPU optimization)
910
0
        for gen_idx in 0..config.max_tokens {
911
            // Collect active prompts for this generation step
912
0
            let active_indices: Vec<usize> = (0..num_prompts).filter(|&i| !done[i]).collect();
913
914
0
            if active_indices.is_empty() {
915
0
                break;
916
0
            }
917
918
0
            let active_count = active_indices.len();
919
920
            // Use batched forward when we have enough active prompts for GPU benefit
921
            // GPU batch threshold is 32 (from IMP-600 analysis)
922
            const GPU_BATCH_THRESHOLD: usize = 32;
923
924
0
            if active_count >= GPU_BATCH_THRESHOLD {
925
                // PARITY-021: Batched forward with GPU FFN
926
                // Collect tokens, positions, and cache slices for active prompts
927
0
                let batch_tokens: Vec<u32> = active_indices
928
0
                    .iter()
929
0
                    .map(|&idx| {
930
0
                        *sequences[idx]
931
0
                            .last()
932
0
                            .expect("sequence must have at least prompt tokens")
933
0
                    })
934
0
                    .collect();
935
936
0
                let batch_positions: Vec<usize> = active_indices
937
0
                    .iter()
938
0
                    .map(|&idx| prompts[idx].len() + gen_idx)
939
0
                    .collect();
940
941
                // PARITY-096: Extract caches without cloning using std::mem::take
942
                // This avoids expensive cache cloning on every generation step
943
0
                let mut batch_caches: Vec<OwnedQuantizedKVCache> = active_indices
944
0
                    .iter()
945
0
                    .map(|&idx| std::mem::take(&mut caches[idx]))
946
0
                    .collect();
947
948
                // Forward batch with GPU FFN
949
0
                let all_logits = self.forward_batch_with_gpu_ffn(
950
0
                    &batch_tokens,
951
0
                    &mut batch_caches,
952
0
                    &batch_positions,
953
0
                )?;
954
955
                // PARITY-096: Put caches back (move, not clone)
956
0
                for (i, &idx) in active_indices.iter().enumerate() {
957
0
                    caches[idx] = std::mem::take(&mut batch_caches[i]);
958
0
                }
959
960
                // Sample and update sequences
961
0
                for (i, &prompt_idx) in active_indices.iter().enumerate() {
962
0
                    let logits = &all_logits[i];
963
0
                    let next_token = if config.temperature == 0.0 || config.top_k == 1 {
964
0
                        OwnedQuantizedModel::argmax(logits)
965
                    } else {
966
0
                        OwnedQuantizedModel::sample_topk(logits, config.temperature, config.top_k)
967
                    };
968
969
0
                    if config.stop_tokens.contains(&next_token) {
970
0
                        done[prompt_idx] = true;
971
0
                    } else {
972
0
                        sequences[prompt_idx].push(next_token);
973
0
                        if sequences[prompt_idx].len() >= max_seq_len {
974
0
                            done[prompt_idx] = true;
975
0
                        }
976
                    }
977
                }
978
            } else {
979
                // Sequential forward for small batches (CPU is faster)
980
0
                for &prompt_idx in &active_indices {
981
0
                    let position = prompts[prompt_idx].len() + gen_idx;
982
0
                    let last_token = *sequences[prompt_idx]
983
0
                        .last()
984
0
                        .expect("sequence must have at least prompt tokens");
985
986
0
                    let logits = self.model.forward_single_with_cache(
987
0
                        last_token,
988
0
                        &mut caches[prompt_idx],
989
0
                        position,
990
0
                    )?;
991
992
0
                    let next_token = if config.temperature == 0.0 || config.top_k == 1 {
993
0
                        OwnedQuantizedModel::argmax(&logits)
994
                    } else {
995
0
                        OwnedQuantizedModel::sample_topk(&logits, config.temperature, config.top_k)
996
                    };
997
998
0
                    if config.stop_tokens.contains(&next_token) {
999
0
                        done[prompt_idx] = true;
1000
0
                    } else {
1001
0
                        sequences[prompt_idx].push(next_token);
1002
0
                        if sequences[prompt_idx].len() >= max_seq_len {
1003
0
                            done[prompt_idx] = true;
1004
0
                        }
1005
                    }
1006
                }
1007
            }
1008
        }
1009
1010
0
        Ok(sequences)
1011
0
    }
1012
1013
    /// Batched forward pass with GPU FFN optimization (PARITY-021)
1014
    ///
1015
    /// Processes multiple tokens in parallel with GPU-accelerated FFN.
1016
    /// Attention is still per-token with CPU KV cache, but FFN uses GPU GEMM.
1017
    ///
1018
    /// # Arguments
1019
    /// * `token_ids` - Token IDs for each prompt [batch_size]
1020
    /// * `caches` - Per-prompt KV caches
1021
    /// * `positions` - Position for each prompt [batch_size]
1022
    ///
1023
    /// # Returns
1024
    /// Logits for each prompt [batch_size][vocab_size]
1025
    ///
1026
    /// # GPU Dispatch
1027
    /// - batch_size >= 32: GPU GEMM for FFN (10x speedup)
1028
    /// - batch_size < 32: CPU fallback
1029
0
    pub fn forward_batch_with_gpu_ffn(
1030
0
        &self,
1031
0
        token_ids: &[u32],
1032
0
        caches: &mut [OwnedQuantizedKVCache],
1033
0
        positions: &[usize],
1034
0
    ) -> Result<Vec<Vec<f32>>> {
1035
0
        let batch_size = token_ids.len();
1036
0
        if batch_size == 0 {
1037
0
            return Ok(Vec::new());
1038
0
        }
1039
0
        if batch_size != caches.len() || batch_size != positions.len() {
1040
0
            return Err(RealizarError::InvalidShape {
1041
0
                reason: format!(
1042
0
                    "Batch size mismatch: tokens={}, caches={}, positions={}",
1043
0
                    batch_size,
1044
0
                    caches.len(),
1045
0
                    positions.len()
1046
0
                ),
1047
0
            });
1048
0
        }
1049
1050
0
        let hidden_dim = self.model.config.hidden_dim;
1051
0
        let num_layers = self.model.layers.len();
1052
1053
        // Threshold for GPU dispatch (based on IMP-600 analysis)
1054
        const GPU_BATCH_THRESHOLD: usize = 32;
1055
0
        let use_gpu = batch_size >= GPU_BATCH_THRESHOLD && self.is_gpu_cache_warm();
1056
1057
        // PARITY-098: Parallel embedding using rayon
1058
        use rayon::prelude::*;
1059
0
        let mut hidden_states: Vec<Vec<f32>> = token_ids
1060
0
            .par_iter()
1061
0
            .map(|&tid| self.model.embed(&[tid]))
1062
0
            .collect();
1063
1064
        // 2. Process through transformer layers
1065
0
        for layer_idx in 0..num_layers {
1066
0
            let layer = &self.model.layers[layer_idx];
1067
1068
            // PARITY-024: GPU batch attention path vs CPU sequential path
1069
0
            if use_gpu {
1070
                // GPU path: batch QKV projection, per-prompt attention, batch output projection
1071
1072
                // 2a. PARITY-098: Parallel batch layer norm
1073
0
                let normed_batch: Vec<Vec<f32>> = hidden_states
1074
0
                    .par_iter()
1075
0
                    .map(|hidden| {
1076
0
                        self.model.layer_norm(
1077
0
                            hidden,
1078
0
                            &layer.attn_norm_weight,
1079
0
                            layer.attn_norm_bias.as_deref(),
1080
0
                            self.model.config.eps,
1081
                        )
1082
0
                    })
1083
0
                    .collect();
1084
1085
                // 2b. Batch QKV projection using GPU GEMM (PARITY-024)
1086
0
                let batch_normed: Vec<f32> = normed_batch.iter().flatten().copied().collect();
1087
0
                let batch_qkv = self.batch_qkv_projection_gpu(&batch_normed, layer_idx)?;
1088
1089
                // 2c-2e. PARITY-099: Parallel attention computation per prompt
1090
                // Each prompt has its own KV cache, so we can parallelize
1091
0
                let qkv_dim = 3 * hidden_dim;
1092
1093
0
                let attention_outputs: Vec<Vec<f32>> = caches
1094
0
                    .par_iter_mut()
1095
0
                    .enumerate()
1096
0
                    .map(|(prompt_idx, cache)| {
1097
0
                        let qkv_start = prompt_idx * qkv_dim;
1098
0
                        let qkv = &batch_qkv[qkv_start..qkv_start + qkv_dim];
1099
1100
                        // Extract Q, K, V
1101
0
                        let mut q = qkv[0..hidden_dim].to_vec();
1102
0
                        let mut k = qkv[hidden_dim..2 * hidden_dim].to_vec();
1103
0
                        let v = qkv[2 * hidden_dim..3 * hidden_dim].to_vec();
1104
1105
                        // Apply RoPE (position-dependent, must be per-prompt)
1106
                        // Note: Uses num_heads for both (non-GQA code path)
1107
0
                        self.model.apply_rope(
1108
0
                            &mut q,
1109
0
                            positions[prompt_idx],
1110
0
                            self.model.config.num_heads,
1111
                        );
1112
0
                        self.model.apply_rope(
1113
0
                            &mut k,
1114
0
                            positions[prompt_idx],
1115
0
                            self.model.config.num_heads,
1116
                        );
1117
1118
                        // Attention with KV cache (must be per-prompt, different caches)
1119
                        // PARITY-027: Use FlashAttention for long sequences (O(N) memory)
1120
0
                        let k_cache = cache.get_k(layer_idx);
1121
0
                        let v_cache = cache.get_v(layer_idx);
1122
1123
                        // FlashAttention threshold: use for sequences >= 512 tokens
1124
                        const FLASH_ATTENTION_THRESHOLD: usize = 512;
1125
0
                        let cache_len = k_cache.len() / hidden_dim;
1126
0
                        let use_flash_attention = cache_len >= FLASH_ATTENTION_THRESHOLD;
1127
1128
0
                        let attn_out = if k_cache.is_empty() {
1129
0
                            v.clone()
1130
0
                        } else if use_flash_attention {
1131
                            // FlashAttention: O(N) memory, tiled computation
1132
                            const FLASH_BLOCK_SIZE: usize = 64;
1133
0
                            self.model.flash_attention_tiled(
1134
0
                                &q,
1135
0
                                k_cache,
1136
0
                                v_cache,
1137
0
                                &k,
1138
0
                                &v,
1139
                                FLASH_BLOCK_SIZE,
1140
                            )
1141
                        } else {
1142
                            // Standard attention: O(N²) memory but faster for short sequences
1143
0
                            self.model
1144
0
                                .attention_with_cache(&q, k_cache, v_cache, &k, &v)
1145
                        };
1146
1147
                        // Store K and V in cache
1148
0
                        cache.append(layer_idx, &k, &v);
1149
0
                        attn_out
1150
0
                    })
1151
0
                    .collect();
1152
1153
                // 2f. Batch attention output projection using GPU GEMM (PARITY-024)
1154
0
                let batch_attn: Vec<f32> = attention_outputs.iter().flatten().copied().collect();
1155
0
                let batch_output = self.batch_attention_output_gpu(&batch_attn, layer_idx)?;
1156
1157
                // 2g. PARITY-100: Parallel residual connection
1158
0
                hidden_states
1159
0
                    .par_iter_mut()
1160
0
                    .enumerate()
1161
0
                    .for_each(|(prompt_idx, hidden)| {
1162
0
                        let start = prompt_idx * hidden_dim;
1163
0
                        for i in 0..hidden_dim {
1164
0
                            hidden[i] += batch_output[start + i];
1165
0
                        }
1166
0
                    });
1167
            } else {
1168
                // CPU sequential path (original implementation)
1169
0
                for (prompt_idx, hidden) in hidden_states.iter_mut().enumerate() {
1170
                    // Attention layer norm
1171
0
                    let normed = self.model.layer_norm(
1172
0
                        hidden,
1173
0
                        &layer.attn_norm_weight,
1174
0
                        layer.attn_norm_bias.as_deref(),
1175
0
                        self.model.config.eps,
1176
                    );
1177
1178
                    // QKV projection
1179
0
                    let mut qkv = self.model.qkv_matmul(&normed, &layer.qkv_weight)?;
1180
0
                    if let Some(ref bias) = layer.qkv_bias {
1181
0
                        self.model.add_bias(&mut qkv, bias);
1182
0
                    }
1183
1184
                    // Extract Q, K, V and apply RoPE
1185
                    // Note: Uses num_heads for both (non-GQA code path)
1186
0
                    let mut q = qkv[0..hidden_dim].to_vec();
1187
0
                    let mut k = qkv[hidden_dim..2 * hidden_dim].to_vec();
1188
0
                    let v = qkv[2 * hidden_dim..3 * hidden_dim].to_vec();
1189
1190
0
                    self.model.apply_rope(
1191
0
                        &mut q,
1192
0
                        positions[prompt_idx],
1193
0
                        self.model.config.num_heads,
1194
                    );
1195
0
                    self.model.apply_rope(
1196
0
                        &mut k,
1197
0
                        positions[prompt_idx],
1198
0
                        self.model.config.num_heads,
1199
                    );
1200
1201
                    // Get cached K/V and compute attention
1202
0
                    let k_cache = caches[prompt_idx].get_k(layer_idx);
1203
0
                    let v_cache = caches[prompt_idx].get_v(layer_idx);
1204
1205
0
                    let attn_out = if k_cache.is_empty() {
1206
0
                        v.clone()
1207
                    } else {
1208
0
                        self.model
1209
0
                            .attention_with_cache(&q, k_cache, v_cache, &k, &v)
1210
                    };
1211
1212
                    // Store K and V in cache
1213
0
                    caches[prompt_idx].append(layer_idx, &k, &v);
1214
1215
                    // Attention output projection
1216
0
                    let mut attn_output = self
1217
0
                        .model
1218
0
                        .fused_matmul(&attn_out, &layer.attn_output_weight)?;
1219
0
                    if let Some(ref bias) = layer.attn_output_bias {
1220
0
                        self.model.add_bias(&mut attn_output, bias);
1221
0
                    }
1222
1223
                    // Residual connection
1224
0
                    for i in 0..hidden_dim {
1225
0
                        hidden[i] += attn_output[i];
1226
0
                    }
1227
                }
1228
            }
1229
1230
            // 2h. FFN - GPU batch or CPU sequential
1231
0
            if use_gpu {
1232
                // GPU batch FFN: collect hidden states, process together, scatter back
1233
0
                let batch_hidden: Vec<f32> = hidden_states.iter().flatten().copied().collect();
1234
0
                let ffn_output = self.batch_ffn_gpu(&batch_hidden, layer_idx)?;
1235
1236
                // PARITY-100: Parallel scatter and residual
1237
0
                hidden_states
1238
0
                    .par_iter_mut()
1239
0
                    .enumerate()
1240
0
                    .for_each(|(prompt_idx, hidden)| {
1241
0
                        let start = prompt_idx * hidden_dim;
1242
0
                        for i in 0..hidden_dim {
1243
0
                            hidden[i] += ffn_output[start + i];
1244
0
                        }
1245
0
                    });
1246
            } else {
1247
                // CPU sequential FFN
1248
0
                for hidden in &mut hidden_states {
1249
0
                    let mut ffn_hidden = self.model.fused_matmul(hidden, &layer.ffn_up_weight)?;
1250
0
                    if let Some(ref bias) = layer.ffn_up_bias {
1251
0
                        self.model.add_bias(&mut ffn_hidden, bias);
1252
0
                    }
1253
0
                    self.model.gelu(&mut ffn_hidden);
1254
1255
0
                    let mut ffn_output = self
1256
0
                        .model
1257
0
                        .fused_matmul(&ffn_hidden, &layer.ffn_down_weight)?;
1258
0
                    if let Some(ref bias) = layer.ffn_down_bias {
1259
0
                        self.model.add_bias(&mut ffn_output, bias);
1260
0
                    }
1261
1262
                    // Residual
1263
0
                    for i in 0..hidden_dim {
1264
0
                        hidden[i] += ffn_output[i];
1265
0
                    }
1266
                }
1267
            }
1268
        }
1269
1270
        // PARITY-100: Parallel cache advance
1271
0
        caches.par_iter_mut().for_each(|cache| {
1272
0
            cache.advance();
1273
0
        });
1274
1275
        // 3. Final layer norm and LM head for each prompt
1276
        // PARITY-025: Use GPU batch LM head when batch >= threshold
1277
0
        let vocab_size = self.model.config.vocab_size;
1278
1279
0
        let all_logits: Vec<Vec<f32>> = if use_gpu {
1280
            // GPU path: batch layer norm and LM head projection
1281
1282
            // 3a. PARITY-098: Parallel final layer norm
1283
0
            let normed_batch: Vec<Vec<f32>> = hidden_states
1284
0
                .par_iter()
1285
0
                .map(|hidden| {
1286
0
                    self.model.layer_norm(
1287
0
                        hidden,
1288
0
                        &self.model.output_norm_weight,
1289
0
                        self.model.output_norm_bias.as_deref(),
1290
0
                        self.model.config.eps,
1291
                    )
1292
0
                })
1293
0
                .collect();
1294
1295
            // 3b. Batch LM head projection using GPU GEMM (PARITY-025)
1296
0
            let batch_normed: Vec<f32> = normed_batch.iter().flatten().copied().collect();
1297
0
            let batch_logits = self.batch_lm_head_gpu(&batch_normed)?;
1298
1299
            // 3c. PARITY-098: Parallel scatter logits back to per-prompt vectors
1300
0
            (0..batch_size)
1301
0
                .into_par_iter()
1302
0
                .map(|i| {
1303
0
                    let start = i * vocab_size;
1304
0
                    batch_logits[start..start + vocab_size].to_vec()
1305
0
                })
1306
0
                .collect()
1307
        } else {
1308
            // CPU path: sequential per-prompt processing
1309
0
            let mut result = Vec::with_capacity(batch_size);
1310
0
            for hidden in &hidden_states {
1311
0
                let normed = self.model.layer_norm(
1312
0
                    hidden,
1313
0
                    &self.model.output_norm_weight,
1314
0
                    self.model.output_norm_bias.as_deref(),
1315
0
                    self.model.config.eps,
1316
                );
1317
1318
0
                let mut logits = self
1319
0
                    .model
1320
0
                    .fused_matmul(&normed, &self.model.lm_head_weight)?;
1321
0
                if let Some(ref bias) = self.model.lm_head_bias {
1322
0
                    self.model.add_bias(&mut logits, bias);
1323
0
                }
1324
0
                result.push(logits);
1325
            }
1326
0
            result
1327
        };
1328
1329
0
        Ok(all_logits)
1330
0
    }
1331
1332
    /// Get batch generation statistics
1333
    ///
1334
    /// Returns information about the batch processing capabilities.
1335
0
    pub fn batch_stats(&self) -> BatchGenerationStats {
1336
0
        let is_cached = self.is_gpu_cache_warm();
1337
0
        let memory_gb = self.gpu_cache_memory() as f64 / 1_000_000_000.0;
1338
0
        let num_layers = self.model.layers.len();
1339
0
        let hidden_dim = self.model.config.hidden_dim;
1340
0
        let intermediate_dim = self.model.config.intermediate_dim;
1341
1342
0
        BatchGenerationStats {
1343
0
            gpu_cache_ready: is_cached,
1344
0
            cache_memory_gb: memory_gb,
1345
0
            num_layers,
1346
0
            hidden_dim,
1347
0
            intermediate_dim,
1348
0
            recommended_batch_size: 32, // GPU GEMM threshold
1349
0
            max_batch_size: 64,         // Memory-limited
1350
0
        }
1351
0
    }
1352
}