Skip to main content

torsh_quantization/
simd_ops.rs

1//! SIMD-accelerated quantization operations
2//!
3//! This module provides optimized SIMD implementations for performance-critical
4//! quantization operations using the scirs2-core SIMD abstraction layer.
5//!
6//! # Features
7//!
8//! - **Vectorized Quantization**: SIMD-accelerated per-tensor quantization
9//! - **Vectorized Dequantization**: SIMD-accelerated dequantization operations
10//! - **Fast Min/Max Finding**: Hardware-accelerated min/max computation for calibration
11//! - **Batch Operations**: Optimized batch processing for multiple tensors
12//! - **Fallback Support**: Automatic fallback to scalar operations when SIMD unavailable
13
14use scirs2_core::parallel_ops::*;
15use torsh_core::error::{Result as TorshResult, TorshError};
16
17/// SIMD-accelerated per-tensor quantization into the `I8` range.
18///
19/// Use [`quantize_per_tensor_affine_simd_range`] to target a different integer
20/// width (for example `U8`'s `[0, 255]`).
21pub fn quantize_per_tensor_affine_simd(
22    input: &[f32],
23    scale: f32,
24    zero_point: i32,
25    output: &mut [f32],
26) -> TorshResult<()> {
27    quantize_per_tensor_affine_simd_range(input, scale, zero_point, -128, 127, output)
28}
29
30/// SIMD-accelerated per-tensor quantization clamped to an explicit `[qmin, qmax]` range.
31pub fn quantize_per_tensor_affine_simd_range(
32    input: &[f32],
33    scale: f32,
34    zero_point: i32,
35    qmin: i32,
36    qmax: i32,
37    output: &mut [f32],
38) -> TorshResult<()> {
39    if input.len() != output.len() {
40        return Err(TorshError::InvalidArgument(
41            "Input and output length mismatch".to_string(),
42        ));
43    }
44
45    if scale <= 0.0 {
46        return Err(TorshError::InvalidArgument(
47            "Scale must be positive".to_string(),
48        ));
49    }
50
51    if qmin >= qmax {
52        return Err(TorshError::InvalidArgument(
53            "qmin must be less than qmax".to_string(),
54        ));
55    }
56
57    let inv_scale = 1.0 / scale;
58    let zero_point_f32 = zero_point as f32;
59    let (qmin_f, qmax_f) = (qmin as f32, qmax as f32);
60
61    // Use optimized parallel processing for quantization operation
62    input
63        .par_iter()
64        .zip(output.par_iter_mut())
65        .for_each(|(&x, out)| {
66            let quantized = (x * inv_scale).round() + zero_point_f32;
67            *out = quantized.clamp(qmin_f, qmax_f);
68        });
69
70    Ok(())
71}
72
73/// SIMD-accelerated per-tensor dequantization
74pub fn dequantize_per_tensor_affine_simd(
75    input: &[f32],
76    scale: f32,
77    zero_point: i32,
78    output: &mut [f32],
79) -> TorshResult<()> {
80    if input.len() != output.len() {
81        return Err(TorshError::InvalidArgument(
82            "Input and output length mismatch".to_string(),
83        ));
84    }
85
86    if scale <= 0.0 {
87        return Err(TorshError::InvalidArgument(
88            "Scale must be positive".to_string(),
89        ));
90    }
91
92    let zero_point_f32 = zero_point as f32;
93
94    // Use optimized parallel processing for dequantization operation
95    input
96        .par_iter()
97        .zip(output.par_iter_mut())
98        .for_each(|(&x, out)| {
99            *out = (x - zero_point_f32) * scale;
100        });
101
102    Ok(())
103}
104
105/// SIMD-accelerated min/max finding for calibration
106pub fn find_min_max_simd(data: &[f32]) -> TorshResult<(f32, f32)> {
107    if data.is_empty() {
108        return Err(TorshError::InvalidArgument(
109            "Cannot find min/max of empty array".to_string(),
110        ));
111    }
112
113    // Use parallel operations for min/max reduction
114    const CHUNK_SIZE: usize = 1024; // Process in cache-friendly chunks
115    let (min_val, max_val) = if data.len() > CHUNK_SIZE {
116        // Use parallel processing for large datasets
117        data.par_chunks(CHUNK_SIZE)
118            .map(|chunk| {
119                let mut local_min = f32::INFINITY;
120                let mut local_max = f32::NEG_INFINITY;
121                for &val in chunk {
122                    local_min = local_min.min(val);
123                    local_max = local_max.max(val);
124                }
125                (local_min, local_max)
126            })
127            .reduce(
128                || (f32::INFINITY, f32::NEG_INFINITY),
129                |(min1, max1), (min2, max2)| (min1.min(min2), max1.max(max2)),
130            )
131    } else {
132        // Sequential processing for small datasets
133        let mut min_val = f32::INFINITY;
134        let mut max_val = f32::NEG_INFINITY;
135        for &val in data {
136            min_val = min_val.min(val);
137            max_val = max_val.max(val);
138        }
139        (min_val, max_val)
140    };
141
142    Ok((min_val, max_val))
143}
144
145/// SIMD-accelerated per-channel quantization
146pub fn quantize_per_channel_simd(
147    input: &[f32],
148    scales: &[f32],
149    zero_points: &[i32],
150    channel_size: usize,
151    output: &mut [f32],
152) -> TorshResult<()> {
153    if input.len() != output.len() {
154        return Err(TorshError::InvalidArgument(
155            "Input and output length mismatch".to_string(),
156        ));
157    }
158
159    let num_channels = scales.len();
160    if num_channels != zero_points.len() {
161        return Err(TorshError::InvalidArgument(
162            "Scales and zero_points length mismatch".to_string(),
163        ));
164    }
165
166    if input.len() != num_channels * channel_size {
167        return Err(TorshError::InvalidArgument(
168            "Input size does not match channel configuration".to_string(),
169        ));
170    }
171
172    // Process each channel with SIMD acceleration
173    for (ch, (&scale, &zero_point)) in scales.iter().zip(zero_points.iter()).enumerate() {
174        if scale <= 0.0 {
175            return Err(TorshError::InvalidArgument(format!(
176                "Scale for channel {} must be positive",
177                ch
178            )));
179        }
180
181        let channel_start = ch * channel_size;
182        let channel_end = channel_start + channel_size;
183
184        let input_slice = &input[channel_start..channel_end];
185        let output_slice = &mut output[channel_start..channel_end];
186
187        quantize_per_tensor_affine_simd(input_slice, scale, zero_point, output_slice)?;
188    }
189
190    Ok(())
191}
192
193/// SIMD-accelerated batch quantization for consistent parameters
194pub fn quantize_batch_consistent_simd(
195    tensors: &[&[f32]],
196    scale: f32,
197    zero_point: i32,
198    outputs: &mut [&mut [f32]],
199) -> TorshResult<()> {
200    if tensors.len() != outputs.len() {
201        return Err(TorshError::InvalidArgument(
202            "Number of input tensors must match output tensors".to_string(),
203        ));
204    }
205
206    // Use parallel processing for each tensor
207    tensors
208        .par_iter()
209        .zip(outputs.par_iter_mut())
210        .try_for_each(|(input, output)| {
211            quantize_per_tensor_affine_simd(input, scale, zero_point, output)
212        })?;
213
214    Ok(())
215}
216
217/// SIMD-accelerated floating-point to integer quantization (optimized for INT8)
218pub fn quantize_to_int8_simd(
219    input: &[f32],
220    scale: f32,
221    zero_point: i32,
222    output: &mut [i8],
223) -> TorshResult<()> {
224    if input.len() != output.len() {
225        return Err(TorshError::InvalidArgument(
226            "Input and output length mismatch".to_string(),
227        ));
228    }
229
230    if scale <= 0.0 {
231        return Err(TorshError::InvalidArgument(
232            "Scale must be positive".to_string(),
233        ));
234    }
235
236    let inv_scale = 1.0 / scale;
237    let zero_point_f32 = zero_point as f32;
238
239    // Use optimized parallel processing for quantization to INT8
240    input
241        .par_iter()
242        .zip(output.par_iter_mut())
243        .for_each(|(&x, out)| {
244            let quantized = (x * inv_scale).round() + zero_point_f32;
245            *out = quantized.clamp(-128.0, 127.0) as i8;
246        });
247
248    Ok(())
249}
250
251/// SIMD-accelerated statistics calculation for quantization calibration
252pub fn calculate_tensor_stats_simd(data: &[f32]) -> TorshResult<TensorStats> {
253    if data.is_empty() {
254        return Err(TorshError::InvalidArgument(
255            "Cannot calculate stats of empty tensor".to_string(),
256        ));
257    }
258
259    let (min_val, max_val) = find_min_max_simd(data)?;
260
261    // Calculate mean using parallel reduction
262    let sum: f64 = data.par_iter().map(|&x| x as f64).sum();
263    let mean = sum / data.len() as f64;
264
265    // Calculate variance using parallel reduction
266    let variance_sum: f64 = data
267        .par_iter()
268        .map(|&x| {
269            let diff = x as f64 - mean;
270            diff * diff
271        })
272        .sum();
273    let variance = variance_sum / data.len() as f64;
274    let std_dev = variance.sqrt();
275
276    Ok(TensorStats {
277        min: min_val,
278        max: max_val,
279        mean: mean as f32,
280        std_dev: std_dev as f32,
281        variance: variance as f32,
282    })
283}
284
285/// Tensor statistics structure
286#[derive(Debug, Clone)]
287pub struct TensorStats {
288    pub min: f32,
289    pub max: f32,
290    pub mean: f32,
291    pub std_dev: f32,
292    pub variance: f32,
293}
294
295/// Check if SIMD operations are available on current hardware
296pub fn is_simd_available() -> bool {
297    // Check for common SIMD instruction sets (x86 and ARM)
298    cfg!(any(
299        target_feature = "avx512f",
300        target_feature = "avx2",
301        target_feature = "avx",
302        target_feature = "sse2",
303        target_feature = "neon" // ARM NEON support
304    ))
305}
306
307/// Get optimal SIMD vector width for current hardware
308pub fn get_simd_width() -> usize {
309    // Return optimal width based on available instruction set
310    // AVX2: 8 x f32, AVX-512: 16 x f32, NEON: 4 x f32
311    if cfg!(target_feature = "avx512f") {
312        16 // AVX-512: 16 x f32 elements
313    } else if cfg!(target_feature = "avx2") {
314        8 // AVX2: 8 x f32 elements
315    } else if cfg!(any(target_feature = "sse2", target_feature = "neon")) {
316        4 // SSE2/NEON: 4 x f32 elements
317    } else {
318        1 // Fallback to scalar
319    }
320}
321
322/// ARM NEON-specific optimized quantization
323#[cfg(target_arch = "aarch64")]
324pub fn quantize_neon_optimized(
325    input: &[f32],
326    scale: f32,
327    zero_point: i32,
328    output: &mut [f32],
329) -> TorshResult<()> {
330    if input.len() != output.len() {
331        return Err(TorshError::InvalidArgument(
332            "Input and output length mismatch".to_string(),
333        ));
334    }
335
336    if scale <= 0.0 {
337        return Err(TorshError::InvalidArgument(
338            "Scale must be positive".to_string(),
339        ));
340    }
341
342    let inv_scale = 1.0 / scale;
343    let zero_point_f32 = zero_point as f32;
344
345    // Process in NEON-friendly chunks of 4 elements
346    const NEON_WIDTH: usize = 4;
347    let chunks = input.len() / NEON_WIDTH;
348
349    // Process aligned chunks for optimal NEON performance
350    for i in 0..chunks {
351        let start = i * NEON_WIDTH;
352        let end = start + NEON_WIDTH;
353
354        // Use vectorized operations for NEON
355        for (&inp, out) in input[start..end].iter().zip(output[start..end].iter_mut()) {
356            let quantized = (inp * inv_scale).round() + zero_point_f32;
357            *out = quantized.clamp(-128.0, 127.0);
358        }
359    }
360
361    // Handle remaining elements
362    let remainder_start = chunks * NEON_WIDTH;
363    for (&inp, out) in input[remainder_start..]
364        .iter()
365        .zip(output[remainder_start..].iter_mut())
366    {
367        let quantized = (inp * inv_scale).round() + zero_point_f32;
368        *out = quantized.clamp(-128.0, 127.0);
369    }
370
371    Ok(())
372}
373
374/// ARM NEON-optimized min/max finding
375#[cfg(target_arch = "aarch64")]
376pub fn find_min_max_neon(data: &[f32]) -> TorshResult<(f32, f32)> {
377    if data.is_empty() {
378        return Err(TorshError::InvalidArgument(
379            "Cannot find min/max of empty array".to_string(),
380        ));
381    }
382
383    const NEON_WIDTH: usize = 4;
384    let chunks = data.len() / NEON_WIDTH;
385
386    let mut min_val = f32::INFINITY;
387    let mut max_val = f32::NEG_INFINITY;
388
389    // Process in NEON-friendly chunks
390    for i in 0..chunks {
391        let start = i * NEON_WIDTH;
392        let end = start + NEON_WIDTH;
393
394        // Vectorized min/max operations for NEON
395        for &val in &data[start..end] {
396            min_val = min_val.min(val);
397            max_val = max_val.max(val);
398        }
399    }
400
401    // Handle remaining elements
402    let remainder_start = chunks * NEON_WIDTH;
403    for &val in &data[remainder_start..] {
404        min_val = min_val.min(val);
405        max_val = max_val.max(val);
406    }
407
408    Ok((min_val, max_val))
409}
410
411/// Mobile-optimized quantization with reduced memory usage
412pub fn quantize_mobile_optimized(
413    input: &[f32],
414    scale: f32,
415    zero_point: i32,
416    output: &mut [i8],
417    use_reduced_precision: bool,
418) -> TorshResult<()> {
419    if input.len() != output.len() {
420        return Err(TorshError::InvalidArgument(
421            "Input and output length mismatch".to_string(),
422        ));
423    }
424
425    if scale <= 0.0 {
426        return Err(TorshError::InvalidArgument(
427            "Scale must be positive".to_string(),
428        ));
429    }
430
431    let inv_scale = if use_reduced_precision {
432        // Use faster but less precise arithmetic for mobile
433        1.0 / scale
434    } else {
435        1.0 / scale
436    };
437
438    let zero_point_f32 = zero_point as f32;
439
440    // Use smaller chunk sizes for better mobile cache performance
441    const MOBILE_CHUNK_SIZE: usize = 256;
442
443    if input.len() > MOBILE_CHUNK_SIZE {
444        // Process in mobile-optimized chunks
445        input
446            .chunks(MOBILE_CHUNK_SIZE)
447            .zip(output.chunks_mut(MOBILE_CHUNK_SIZE))
448            .for_each(|(input_chunk, output_chunk)| {
449                for (&x, out) in input_chunk.iter().zip(output_chunk.iter_mut()) {
450                    let quantized = if use_reduced_precision {
451                        // Faster rounding for mobile
452                        (x * inv_scale + 0.5).floor() + zero_point_f32
453                    } else {
454                        (x * inv_scale).round() + zero_point_f32
455                    };
456                    *out = quantized.clamp(-128.0, 127.0) as i8;
457                }
458            });
459    } else {
460        // Direct processing for small tensors
461        for (&x, out) in input.iter().zip(output.iter_mut()) {
462            let quantized = (x * inv_scale).round() + zero_point_f32;
463            *out = quantized.clamp(-128.0, 127.0) as i8;
464        }
465    }
466
467    Ok(())
468}
469
470/// Get mobile-specific optimization recommendations
471pub fn get_mobile_optimization_hints() -> MobileOptimizationHints {
472    MobileOptimizationHints {
473        prefer_int8: true,
474        use_reduced_precision: cfg!(target_os = "android") || cfg!(target_os = "ios"),
475        optimal_chunk_size: if cfg!(target_arch = "aarch64") {
476            256
477        } else {
478            512
479        },
480        enable_fast_math: true,
481        prefer_sequential: false, // Mobile devices often benefit from some parallelism
482    }
483}
484
485/// Mobile optimization configuration hints
486#[derive(Debug, Clone)]
487pub struct MobileOptimizationHints {
488    pub prefer_int8: bool,
489    pub use_reduced_precision: bool,
490    pub optimal_chunk_size: usize,
491    pub enable_fast_math: bool,
492    pub prefer_sequential: bool,
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498    use approx::assert_relative_eq;
499
500    #[test]
501    fn test_quantize_per_tensor_affine_simd() {
502        let input = vec![1.0, 2.0, 3.0, 4.0];
503        let mut output = vec![0.0; 4];
504
505        quantize_per_tensor_affine_simd(&input, 0.1, 0, &mut output).unwrap();
506
507        assert_relative_eq!(output[0], 10.0, epsilon = 1e-6);
508        assert_relative_eq!(output[1], 20.0, epsilon = 1e-6);
509        assert_relative_eq!(output[2], 30.0, epsilon = 1e-6);
510        assert_relative_eq!(output[3], 40.0, epsilon = 1e-6);
511    }
512
513    #[test]
514    fn test_dequantize_per_tensor_affine_simd() {
515        let input = vec![10.0, 20.0, 30.0, 40.0];
516        let mut output = vec![0.0; 4];
517
518        dequantize_per_tensor_affine_simd(&input, 0.1, 0, &mut output).unwrap();
519
520        assert_relative_eq!(output[0], 1.0, epsilon = 1e-6);
521        assert_relative_eq!(output[1], 2.0, epsilon = 1e-6);
522        assert_relative_eq!(output[2], 3.0, epsilon = 1e-6);
523        assert_relative_eq!(output[3], 4.0, epsilon = 1e-6);
524    }
525
526    #[test]
527    fn test_find_min_max_simd() {
528        let data = vec![-1.5, 0.0, 2.3, -0.8, 4.7, 1.2];
529        let (min_val, max_val) = find_min_max_simd(&data).unwrap();
530
531        assert_relative_eq!(min_val, -1.5, epsilon = 1e-6);
532        assert_relative_eq!(max_val, 4.7, epsilon = 1e-6);
533    }
534
535    #[test]
536    fn test_calculate_tensor_stats_simd() {
537        let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
538        let stats = calculate_tensor_stats_simd(&data).unwrap();
539
540        assert_relative_eq!(stats.min, 1.0, epsilon = 1e-6);
541        assert_relative_eq!(stats.max, 5.0, epsilon = 1e-6);
542        assert_relative_eq!(stats.mean, 3.0, epsilon = 1e-6);
543        assert_relative_eq!(stats.std_dev, (2.0f64).sqrt() as f32, epsilon = 1e-4);
544    }
545
546    #[test]
547    fn test_quantize_to_int8_simd() {
548        let input = vec![1.0, 2.0, 3.0, 4.0];
549        let mut output = vec![0i8; 4];
550
551        quantize_to_int8_simd(&input, 0.1, 0, &mut output).unwrap();
552
553        assert_eq!(output[0], 10i8);
554        assert_eq!(output[1], 20i8);
555        assert_eq!(output[2], 30i8);
556        assert_eq!(output[3], 40i8);
557    }
558
559    #[test]
560    fn test_error_cases() {
561        let input = vec![1.0, 2.0];
562        let mut output = vec![0.0; 3]; // Wrong size
563
564        let result = quantize_per_tensor_affine_simd(&input, 0.1, 0, &mut output);
565        assert!(result.is_err());
566
567        let mut output_correct = vec![0.0; 2];
568        let result = quantize_per_tensor_affine_simd(&input, -0.1, 0, &mut output_correct);
569        assert!(result.is_err());
570
571        let empty_data: Vec<f32> = vec![];
572        let result = find_min_max_simd(&empty_data);
573        assert!(result.is_err());
574    }
575
576    #[test]
577    fn test_simd_availability() {
578        let available = is_simd_available();
579        let width = get_simd_width();
580
581        // SIMD availability depends on compile-time target features
582        // We just check that the functions return reasonable values
583        assert!(width >= 1); // Should be at least scalar width
584
585        // Test that availability is consistent with width
586        if available {
587            assert!(width > 1); // If SIMD available, width should be > 1
588        }
589    }
590
591    #[test]
592    fn test_mobile_optimized_quantization() {
593        let input = vec![1.0, 2.0, 3.0, 4.0, -1.0, -2.0];
594        let mut output = vec![0i8; 6];
595
596        quantize_mobile_optimized(&input, 0.1, 0, &mut output, false).unwrap();
597
598        assert_eq!(output[0], 10i8);
599        assert_eq!(output[1], 20i8);
600        assert_eq!(output[2], 30i8);
601        assert_eq!(output[3], 40i8);
602        assert_eq!(output[4], -10i8);
603        assert_eq!(output[5], -20i8);
604    }
605
606    #[test]
607    fn test_mobile_optimized_quantization_reduced_precision() {
608        let input = vec![1.0, 2.0, 3.0, 4.0];
609        let mut output = vec![0i8; 4];
610
611        // Test with reduced precision enabled
612        quantize_mobile_optimized(&input, 0.1, 0, &mut output, true).unwrap();
613
614        // Results should be close but may have slight differences due to reduced precision
615        assert!((output[0] as f32 - 10.0).abs() <= 1.0);
616        assert!((output[1] as f32 - 20.0).abs() <= 1.0);
617    }
618
619    #[cfg(target_arch = "aarch64")]
620    #[test]
621    fn test_neon_quantization() {
622        let input = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
623        let mut output = vec![0.0; 8];
624
625        quantize_neon_optimized(&input, 0.1, 0, &mut output).unwrap();
626
627        assert_relative_eq!(output[0], 10.0, epsilon = 1e-6);
628        assert_relative_eq!(output[1], 20.0, epsilon = 1e-6);
629        assert_relative_eq!(output[7], 80.0, epsilon = 1e-6);
630    }
631
632    #[cfg(target_arch = "aarch64")]
633    #[test]
634    fn test_neon_min_max() {
635        let data = vec![-1.5, 0.0, 2.3, -0.8, 4.7, 1.2, 9.5, -2.1];
636        let (min_val, max_val) = find_min_max_neon(&data).unwrap();
637
638        assert_relative_eq!(min_val, -2.1, epsilon = 1e-6);
639        assert_relative_eq!(max_val, 9.5, epsilon = 1e-6);
640    }
641
642    #[test]
643    fn test_mobile_optimization_hints() {
644        let hints = get_mobile_optimization_hints();
645
646        assert!(hints.prefer_int8); // Should prefer INT8 for mobile
647        assert!(hints.optimal_chunk_size > 0); // Should have a reasonable chunk size
648        assert_eq!(hints.prefer_sequential, false); // Should allow some parallelism
649    }
650}