Skip to main content

oxicuda_sparse/ops/
mixed_precision_spmv.rs

1//! Mixed-precision sparse matrix-vector multiplication (SpMV).
2//!
3//! Provides FP16/BF16 storage with FP32 accumulation for memory-bandwidth-bound
4//! SpMV operations. This approach halves the memory footprint of matrix values
5//! while maintaining FP32 numerical quality during the dot-product accumulation,
6//! yielding up to 2x bandwidth savings on bandwidth-limited GPUs.
7//!
8//! Three kernel strategies are available:
9//! - **Scalar**: one thread per row, FP16 load -> FP32 FMA
10//! - **Vector**: one warp per row with FP32 shuffle reduction
11//! - **VectorPacked**: loads two FP16 values per 32-bit memory transaction (2x bandwidth)
12//! - **Auto**: auto-selects based on average nnz per row
13
14use oxicuda_ptx::prelude::*;
15
16use crate::error::{SparseError, SparseResult};
17
18// ---------------------------------------------------------------------------
19// Configuration enums
20// ---------------------------------------------------------------------------
21
22/// Storage precision for matrix values.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
24pub enum StoragePrecision {
25    /// IEEE 754 half-precision (FP16, 10-bit mantissa).
26    Fp16,
27    /// Brain floating-point (BF16, 7-bit mantissa, wider exponent range).
28    Bf16,
29}
30
31impl StoragePrecision {
32    /// Returns the PTX type corresponding to this storage precision.
33    #[must_use]
34    pub const fn ptx_type(self) -> PtxType {
35        match self {
36            Self::Fp16 => PtxType::F16,
37            Self::Bf16 => PtxType::BF16,
38        }
39    }
40
41    /// Returns the packed (x2) PTX type for this precision.
42    #[must_use]
43    pub const fn packed_ptx_type(self) -> PtxType {
44        match self {
45            Self::Fp16 => PtxType::F16x2,
46            Self::Bf16 => PtxType::BF16x2,
47        }
48    }
49
50    /// Bytes per element.
51    #[must_use]
52    pub const fn element_bytes(self) -> u32 {
53        2
54    }
55
56    /// Mantissa bits (for error analysis).
57    #[must_use]
58    pub const fn mantissa_bits(self) -> u32 {
59        match self {
60            Self::Fp16 => 10,
61            Self::Bf16 => 7,
62        }
63    }
64
65    /// Returns the PTX type suffix string without the leading dot.
66    #[must_use]
67    pub const fn suffix(self) -> &'static str {
68        match self {
69            Self::Fp16 => "f16",
70            Self::Bf16 => "bf16",
71        }
72    }
73}
74
75/// Compute/accumulation precision.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
77pub enum ComputePrecision {
78    /// IEEE 754 single-precision (32-bit).
79    Fp32,
80    /// IEEE 754 double-precision (64-bit).
81    Fp64,
82}
83
84impl ComputePrecision {
85    /// Returns the PTX type for the compute precision.
86    #[must_use]
87    pub const fn ptx_type(self) -> PtxType {
88        match self {
89            Self::Fp32 => PtxType::F32,
90            Self::Fp64 => PtxType::F64,
91        }
92    }
93
94    /// Bytes per element.
95    #[must_use]
96    pub const fn element_bytes(self) -> u32 {
97        match self {
98            Self::Fp32 => 4,
99            Self::Fp64 => 8,
100        }
101    }
102
103    /// Returns the PTX suffix without the dot.
104    #[must_use]
105    pub const fn suffix(self) -> &'static str {
106        match self {
107            Self::Fp32 => "f32",
108            Self::Fp64 => "f64",
109        }
110    }
111
112    /// Returns the bit-move suffix for `mov.bN` instructions.
113    #[must_use]
114    pub const fn bit_suffix(self) -> &'static str {
115        match self {
116            Self::Fp32 => "b32",
117            Self::Fp64 => "b64",
118        }
119    }
120}
121
122/// Algorithm selection for mixed-precision SpMV.
123#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
124pub enum MixedSpMVAlgo {
125    /// One thread per row. Best for very sparse matrices (< 4 nnz/row).
126    Scalar,
127    /// One warp (32 threads) per row with FP32 warp shuffle reduction.
128    Vector,
129    /// Vector algorithm with packed 2xFP16 loads (doubles effective bandwidth).
130    /// Each 32-bit load fetches two FP16 values simultaneously.
131    VectorPacked,
132    /// Automatically selects the best algorithm based on matrix structure.
133    Auto,
134}
135
136/// Configuration for mixed-precision SpMV.
137#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
138pub struct MixedPrecisionConfig {
139    /// Storage precision for matrix values (FP16 or BF16).
140    pub storage_precision: StoragePrecision,
141    /// Compute/accumulation precision (FP32 for now).
142    pub compute_precision: ComputePrecision,
143    /// Algorithm selection.
144    pub algorithm: MixedSpMVAlgo,
145    /// Target GPU architecture.
146    pub sm_version: SmVersion,
147}
148
149impl MixedPrecisionConfig {
150    /// Creates a new configuration with FP16 storage and FP32 accumulation.
151    #[must_use]
152    pub const fn fp16_fp32(algo: MixedSpMVAlgo, sm: SmVersion) -> Self {
153        Self {
154            storage_precision: StoragePrecision::Fp16,
155            compute_precision: ComputePrecision::Fp32,
156            algorithm: algo,
157            sm_version: sm,
158        }
159    }
160
161    /// Creates a new configuration with BF16 storage and FP32 accumulation.
162    #[must_use]
163    pub const fn bf16_fp32(algo: MixedSpMVAlgo, sm: SmVersion) -> Self {
164        Self {
165            storage_precision: StoragePrecision::Bf16,
166            compute_precision: ComputePrecision::Fp32,
167            algorithm: algo,
168            sm_version: sm,
169        }
170    }
171}
172
173// ---------------------------------------------------------------------------
174// Execution plan
175// ---------------------------------------------------------------------------
176
177/// Pre-computed execution plan for mixed-precision SpMV.
178///
179/// Contains the resolved configuration, estimated bandwidth savings, and
180/// memory footprint information needed to launch the kernel.
181#[derive(Debug, Clone)]
182pub struct MixedPrecisionPlan {
183    /// Resolved configuration (Auto replaced with concrete algorithm).
184    pub config: MixedPrecisionConfig,
185    /// Number of matrix rows.
186    pub rows: u32,
187    /// Number of matrix columns.
188    pub cols: u32,
189    /// Number of non-zero entries.
190    pub nnz: u64,
191    /// Memory footprint in bytes for the FP16/BF16 values array.
192    pub values_bytes: u64,
193    /// Memory footprint in bytes for the same values if stored in compute precision.
194    pub values_bytes_full: u64,
195}
196
197impl MixedPrecisionPlan {
198    /// Returns the bandwidth savings ratio (e.g., ~2.0 for FP16 vs FP32 values).
199    ///
200    /// This only accounts for the values array; row_offsets and col_indices remain
201    /// unchanged as i32 arrays.
202    #[must_use]
203    pub fn bandwidth_savings_ratio(&self) -> f64 {
204        if self.values_bytes == 0 {
205            return 1.0;
206        }
207        self.values_bytes_full as f64 / self.values_bytes as f64
208    }
209
210    /// Estimates peak achievable GFLOPS assuming the kernel is perfectly
211    /// memory-bandwidth-bound.
212    ///
213    /// Uses a simple roofline model: each non-zero requires one FMA (2 flops),
214    /// plus the memory traffic to load the value, column index, and vector element.
215    #[must_use]
216    pub fn estimated_gflops(&self, peak_bandwidth_gb_s: f64) -> f64 {
217        if self.nnz == 0 {
218            return 0.0;
219        }
220        // Bytes per non-zero: storage_bytes (value) + 4 (col_idx) + compute_bytes (x[col])
221        let bytes_per_nnz = self.config.storage_precision.element_bytes() as f64
222            + 4.0
223            + self.config.compute_precision.element_bytes() as f64;
224        let total_bytes = bytes_per_nnz * self.nnz as f64;
225        // 2 flops per nnz (one multiply, one add in FMA)
226        let total_flops = 2.0 * self.nnz as f64;
227        // bandwidth in bytes/s
228        let bandwidth_bytes_s = peak_bandwidth_gb_s * 1e9;
229        // time = bytes / bandwidth
230        let time_s = total_bytes / bandwidth_bytes_s;
231        // GFLOPS = flops / time / 1e9
232        total_flops / time_s / 1e9
233    }
234
235    /// Returns the average non-zeros per row.
236    #[must_use]
237    pub fn avg_nnz_per_row(&self) -> f64 {
238        if self.rows == 0 {
239            return 0.0;
240        }
241        self.nnz as f64 / self.rows as f64
242    }
243}
244
245/// Performance statistics from a mixed-precision SpMV execution.
246#[derive(Debug, Clone)]
247pub struct MixedPrecisionStats {
248    /// Elapsed wall-clock time in microseconds.
249    pub elapsed_us: f64,
250    /// Achieved GFLOPS (2 * nnz / elapsed).
251    pub gflops: f64,
252    /// Achieved bandwidth in GB/s.
253    pub bandwidth_gb_s: f64,
254    /// Estimated relative precision loss (upper bound).
255    pub precision_loss_bound: f64,
256}
257
258// ---------------------------------------------------------------------------
259// Threshold for auto algorithm selection
260// ---------------------------------------------------------------------------
261
262/// Average nnz/row threshold above which Vector is preferred over Scalar.
263const VECTOR_THRESHOLD: f64 = 4.0;
264
265/// Average nnz/row threshold above which VectorPacked is preferred over Vector.
266const PACKED_THRESHOLD: f64 = 32.0;
267
268/// Block size for scalar kernels.
269const SCALAR_BLOCK_SIZE: u32 = 256;
270
271/// Block size for vector/packed kernels (must be multiple of 32).
272const VECTOR_BLOCK_SIZE: u32 = 256;
273
274// ---------------------------------------------------------------------------
275// Plan creation
276// ---------------------------------------------------------------------------
277
278/// Creates an execution plan for mixed-precision SpMV.
279///
280/// Resolves the `Auto` algorithm based on the matrix dimensions, validates
281/// the configuration, and computes memory/bandwidth estimates.
282///
283/// # Errors
284///
285/// Returns [`SparseError::InvalidArgument`] if the configuration is invalid
286/// (e.g., BF16 on unsupported architecture, zero dimensions).
287pub fn plan_mixed_precision_spmv(
288    config: &MixedPrecisionConfig,
289    rows: u32,
290    cols: u32,
291    nnz: u64,
292) -> SparseResult<MixedPrecisionPlan> {
293    validate_mixed_precision_config(config)?;
294
295    let avg_nnz = if rows > 0 {
296        nnz as f64 / rows as f64
297    } else {
298        0.0
299    };
300
301    // Resolve Auto algorithm
302    let resolved_algo = match config.algorithm {
303        MixedSpMVAlgo::Auto => {
304            if avg_nnz >= PACKED_THRESHOLD {
305                MixedSpMVAlgo::VectorPacked
306            } else if avg_nnz >= VECTOR_THRESHOLD {
307                MixedSpMVAlgo::Vector
308            } else {
309                MixedSpMVAlgo::Scalar
310            }
311        }
312        other => other,
313    };
314
315    let resolved_config = MixedPrecisionConfig {
316        algorithm: resolved_algo,
317        ..*config
318    };
319
320    let values_bytes = nnz * config.storage_precision.element_bytes() as u64;
321    let values_bytes_full = nnz * config.compute_precision.element_bytes() as u64;
322
323    Ok(MixedPrecisionPlan {
324        config: resolved_config,
325        rows,
326        cols,
327        nnz,
328        values_bytes,
329        values_bytes_full,
330    })
331}
332
333// ---------------------------------------------------------------------------
334// Config validation
335// ---------------------------------------------------------------------------
336
337/// Validates a mixed-precision configuration.
338///
339/// Checks that BF16 is only used on architectures that support it (sm_80+)
340/// and that the compute precision is compatible.
341///
342/// # Errors
343///
344/// Returns [`SparseError::InvalidArgument`] if the config is invalid.
345pub fn validate_mixed_precision_config(config: &MixedPrecisionConfig) -> SparseResult<()> {
346    // BF16 requires Ampere (sm_80) or newer
347    if config.storage_precision == StoragePrecision::Bf16 {
348        let (major, _minor) = config.sm_version.ptx_isa_version();
349        if major < 7 {
350            return Err(SparseError::InvalidArgument(
351                "BF16 storage requires sm_80 (Ampere) or newer; \
352                 the selected SM version does not support BF16 instructions"
353                    .to_string(),
354            ));
355        }
356    }
357
358    // FP64 compute is only meaningful with FP16 storage (BF16 range can overflow in F32
359    // but F64 compute with BF16 is valid)
360    if config.compute_precision == ComputePrecision::Fp64 {
361        // FP64 compute is supported but rarely beneficial for SpMV on modern GPUs.
362        // Allow it but warn via documentation that throughput may be low.
363    }
364
365    Ok(())
366}
367
368// ---------------------------------------------------------------------------
369// Precision loss estimation
370// ---------------------------------------------------------------------------
371
372/// Estimates the theoretical relative error bound for mixed-precision SpMV.
373///
374/// For a dot product of length `n` with FP16 storage and FP32 accumulation,
375/// the relative error is bounded by:
376///
377///   `n * eps_storage + n * eps_compute`
378///
379/// where `eps_storage = 2^{-(p+1)}` for `p` mantissa bits, and
380/// `eps_compute = 2^{-24}` for FP32.
381///
382/// This is a pessimistic (worst-case) bound; typical error is much smaller.
383#[must_use]
384pub fn estimate_precision_loss(nnz_per_row: f64, storage: StoragePrecision) -> f64 {
385    let eps_storage = match storage {
386        StoragePrecision::Fp16 => f64::powi(2.0, -11), // 2^-11 for 10-bit mantissa
387        StoragePrecision::Bf16 => f64::powi(2.0, -8),  // 2^-8 for 7-bit mantissa
388    };
389    let eps_compute: f64 = f64::powi(2.0, -24); // FP32
390
391    // Standard floating-point error accumulation bound for length-n dot product
392    nnz_per_row * eps_storage + nnz_per_row * eps_compute
393}
394
395// ---------------------------------------------------------------------------
396// PTX Generation: Scalar kernel
397// ---------------------------------------------------------------------------
398
399/// Generates PTX for scalar mixed-precision SpMV (one thread per row).
400///
401/// Each thread loads FP16/BF16 values from global memory, converts them
402/// to FP32, performs FMA accumulation in FP32, and writes the FP32 result.
403///
404/// Kernel signature:
405/// ```text
406/// .entry mixed_spmv_scalar(
407///     .param .u64 row_ptr,     // i32 CSR row offsets
408///     .param .u64 col_idx,     // i32 column indices
409///     .param .u64 values,      // FP16/BF16 values
410///     .param .u64 x_ptr,       // FP32 dense vector x
411///     .param .u64 y_ptr,       // FP32 dense vector y
412///     .param .u32 alpha_bits,  // FP32 alpha as u32 bits
413///     .param .u32 beta_bits,   // FP32 beta as u32 bits
414///     .param .u32 num_rows
415/// )
416/// ```
417///
418/// # Errors
419///
420/// Returns [`PtxGenError`] if kernel assembly fails.
421pub fn generate_mixed_scalar_spmv_ptx(
422    config: &MixedPrecisionConfig,
423) -> Result<String, PtxGenError> {
424    let storage = config.storage_precision;
425    let compute = config.compute_precision;
426    let sm = config.sm_version;
427    let storage_suffix = storage.suffix();
428    let compute_suffix = compute.suffix();
429    let compute_bit = compute.bit_suffix();
430    let elem_bytes = storage.element_bytes();
431
432    KernelBuilder::new("mixed_spmv_scalar")
433        .target(sm)
434        .param("row_ptr", PtxType::U64)
435        .param("col_idx", PtxType::U64)
436        .param("values", PtxType::U64)
437        .param("x_ptr", PtxType::U64)
438        .param("y_ptr", PtxType::U64)
439        .param("alpha_bits", PtxType::U32)
440        .param("beta_bits", PtxType::U32)
441        .param("num_rows", PtxType::U32)
442        .body(move |b| {
443            let gid = b.global_thread_id_x();
444            let num_rows = b.load_param_u32("num_rows");
445
446            let gid_inner = gid.clone();
447            b.if_lt_u32(gid, num_rows, move |b| {
448                let row = gid_inner;
449                let row_ptr_base = b.load_param_u64("row_ptr");
450                let col_idx_base = b.load_param_u64("col_idx");
451                let values_base = b.load_param_u64("values");
452                let x_ptr = b.load_param_u64("x_ptr");
453                let y_ptr = b.load_param_u64("y_ptr");
454
455                // Load alpha/beta as FP32 from bit patterns
456                let alpha_bits = b.load_param_u32("alpha_bits");
457                let alpha = b.alloc_reg(compute.ptx_type());
458                b.raw_ptx(&format!("mov.{compute_bit} {alpha}, {alpha_bits};"));
459
460                let beta_bits = b.load_param_u32("beta_bits");
461                let beta = b.alloc_reg(compute.ptx_type());
462                b.raw_ptx(&format!("mov.{compute_bit} {beta}, {beta_bits};"));
463
464                // Load row_ptr[row] and row_ptr[row+1]
465                let rp_addr = b.byte_offset_addr(row_ptr_base.clone(), row.clone(), 4);
466                let row_start = b.load_global_i32(rp_addr);
467
468                let row_plus_1 = b.alloc_reg(PtxType::U32);
469                b.raw_ptx(&format!("add.u32 {row_plus_1}, {row}, 1;"));
470                let rp_addr_next = b.byte_offset_addr(row_ptr_base, row_plus_1, 4);
471                let row_end = b.load_global_i32(rp_addr_next);
472
473                // Initialize FP32 accumulator to 0
474                let acc = b.alloc_reg(compute.ptx_type());
475                let zero_bits: u32 = 0u32;
476                b.raw_ptx(&format!("mov.{compute_bit} {acc}, 0F{zero_bits:08X};"));
477
478                // Loop setup
479                let loop_label = b.fresh_label("mpspmv_loop");
480                let done_label = b.fresh_label("mpspmv_done");
481
482                let k = b.alloc_reg(PtxType::U32);
483                let rs_u32 = b.alloc_reg(PtxType::U32);
484                b.raw_ptx(&format!("mov.b32 {rs_u32}, {row_start};"));
485                b.raw_ptx(&format!("mov.u32 {k}, {rs_u32};"));
486
487                let re_u32 = b.alloc_reg(PtxType::U32);
488                b.raw_ptx(&format!("mov.b32 {re_u32}, {row_end};"));
489
490                b.label(&loop_label);
491                let pred = b.alloc_reg(PtxType::Pred);
492                b.raw_ptx(&format!("setp.lo.u32 {pred}, {k}, {re_u32};"));
493                b.raw_ptx(&format!("@!{pred} bra {done_label};"));
494
495                // Load col_idx[k]
496                let ci_addr = b.byte_offset_addr(col_idx_base.clone(), k.clone(), 4);
497                let col = b.load_global_i32(ci_addr);
498                let col_u32 = b.alloc_reg(PtxType::U32);
499                b.raw_ptx(&format!("mov.b32 {col_u32}, {col};"));
500
501                // Load FP16/BF16 value and convert to FP32
502                let v_addr = b.byte_offset_addr(values_base.clone(), k.clone(), elem_bytes);
503                let val_half = b.alloc_reg(storage.ptx_type());
504                b.raw_ptx(&format!(
505                    "ld.global.{storage_suffix} {val_half}, [{v_addr}];"
506                ));
507                let val_fp32 = b.alloc_reg(compute.ptx_type());
508                b.raw_ptx(&format!(
509                    "cvt.{compute_suffix}.{storage_suffix} {val_fp32}, {val_half};"
510                ));
511
512                // Load x[col] (already FP32)
513                let x_addr = b.byte_offset_addr(x_ptr.clone(), col_u32, compute.element_bytes());
514                let x_val = b.load_global_f32(x_addr);
515
516                // FMA: acc += val_fp32 * x_val
517                let new_acc = b.fma_f32(val_fp32, x_val, acc.clone());
518                b.raw_ptx(&format!("mov.{compute_suffix} {acc}, {new_acc};"));
519
520                // k++
521                b.raw_ptx(&format!("add.u32 {k}, {k}, 1;"));
522                b.branch(&loop_label);
523                b.label(&done_label);
524
525                // y = alpha * acc + beta * y_old
526                let y_addr = b.byte_offset_addr(y_ptr, row, compute.element_bytes());
527                let y_old = b.load_global_f32(y_addr.clone());
528
529                let alpha_acc = b.alloc_reg(compute.ptx_type());
530                b.raw_ptx(&format!(
531                    "mul.rn.{compute_suffix} {alpha_acc}, {alpha}, {acc};"
532                ));
533
534                let beta_y = b.alloc_reg(compute.ptx_type());
535                b.raw_ptx(&format!(
536                    "mul.rn.{compute_suffix} {beta_y}, {beta}, {y_old};"
537                ));
538
539                let result = b.alloc_reg(compute.ptx_type());
540                b.raw_ptx(&format!(
541                    "add.{compute_suffix} {result}, {alpha_acc}, {beta_y};"
542                ));
543
544                b.store_global_f32(y_addr, result);
545            });
546
547            b.ret();
548        })
549        .build()
550}
551
552// ---------------------------------------------------------------------------
553// PTX Generation: Vector kernel (warp-parallel)
554// ---------------------------------------------------------------------------
555
556/// Generates PTX for vector mixed-precision SpMV (one warp per row).
557///
558/// Each warp cooperatively processes one row. Lanes stride over the non-zeros,
559/// load FP16/BF16, convert to FP32, accumulate, and finally reduce via warp
560/// shuffles. Lane 0 writes the final result.
561///
562/// # Errors
563///
564/// Returns [`PtxGenError`] if kernel assembly fails.
565pub fn generate_mixed_vector_spmv_ptx(
566    config: &MixedPrecisionConfig,
567) -> Result<String, PtxGenError> {
568    let storage = config.storage_precision;
569    let compute = config.compute_precision;
570    let sm = config.sm_version;
571    let storage_suffix = storage.suffix();
572    let compute_suffix = compute.suffix();
573    let compute_bit = compute.bit_suffix();
574    let elem_bytes = storage.element_bytes();
575
576    KernelBuilder::new("mixed_spmv_vector")
577        .target(sm)
578        .param("row_ptr", PtxType::U64)
579        .param("col_idx", PtxType::U64)
580        .param("values", PtxType::U64)
581        .param("x_ptr", PtxType::U64)
582        .param("y_ptr", PtxType::U64)
583        .param("alpha_bits", PtxType::U32)
584        .param("beta_bits", PtxType::U32)
585        .param("num_rows", PtxType::U32)
586        .body(move |b| {
587            let tid_global = b.global_thread_id_x();
588            let num_rows = b.load_param_u32("num_rows");
589
590            // Lane within warp
591            let lane = b.alloc_reg(PtxType::U32);
592            b.raw_ptx(&format!("and.b32 {lane}, {tid_global}, 31;"));
593
594            // Warp ID
595            let warp_id = b.alloc_reg(PtxType::U32);
596            b.raw_ptx(&format!("shr.u32 {warp_id}, {tid_global}, 5;"));
597
598            let warp_id_inner = warp_id.clone();
599            let lane_inner = lane.clone();
600            b.if_lt_u32(warp_id, num_rows, move |b| {
601                let row = warp_id_inner;
602                let lane = lane_inner;
603
604                let row_ptr_base = b.load_param_u64("row_ptr");
605                let col_idx_base = b.load_param_u64("col_idx");
606                let values_base = b.load_param_u64("values");
607                let x_ptr = b.load_param_u64("x_ptr");
608                let y_ptr = b.load_param_u64("y_ptr");
609
610                let alpha_bits = b.load_param_u32("alpha_bits");
611                let alpha = b.alloc_reg(compute.ptx_type());
612                b.raw_ptx(&format!("mov.{compute_bit} {alpha}, {alpha_bits};"));
613
614                let beta_bits = b.load_param_u32("beta_bits");
615                let beta = b.alloc_reg(compute.ptx_type());
616                b.raw_ptx(&format!("mov.{compute_bit} {beta}, {beta_bits};"));
617
618                // Load row bounds
619                let rp_addr = b.byte_offset_addr(row_ptr_base.clone(), row.clone(), 4);
620                let row_start_i32 = b.load_global_i32(rp_addr);
621                let row_start = b.alloc_reg(PtxType::U32);
622                b.raw_ptx(&format!("mov.b32 {row_start}, {row_start_i32};"));
623
624                let row_plus_1 = b.alloc_reg(PtxType::U32);
625                b.raw_ptx(&format!("add.u32 {row_plus_1}, {row}, 1;"));
626                let rp_addr_next = b.byte_offset_addr(row_ptr_base, row_plus_1, 4);
627                let row_end_i32 = b.load_global_i32(rp_addr_next);
628                let row_end = b.alloc_reg(PtxType::U32);
629                b.raw_ptx(&format!("mov.b32 {row_end}, {row_end_i32};"));
630
631                // Each lane starts at row_start + lane, stride 32
632                let acc = b.alloc_reg(compute.ptx_type());
633                let zero_bits: u32 = 0u32;
634                b.raw_ptx(&format!("mov.{compute_bit} {acc}, 0F{zero_bits:08X};"));
635
636                let k = b.alloc_reg(PtxType::U32);
637                b.raw_ptx(&format!("add.u32 {k}, {row_start}, {lane};"));
638
639                let loop_label = b.fresh_label("mpspmv_vloop");
640                let done_label = b.fresh_label("mpspmv_vdone");
641
642                b.label(&loop_label);
643                let pred = b.alloc_reg(PtxType::Pred);
644                b.raw_ptx(&format!("setp.lo.u32 {pred}, {k}, {row_end};"));
645                b.raw_ptx(&format!("@!{pred} bra {done_label};"));
646
647                // Load col and half-precision value
648                let ci_addr = b.byte_offset_addr(col_idx_base.clone(), k.clone(), 4);
649                let col_i32 = b.load_global_i32(ci_addr);
650                let col_u32 = b.alloc_reg(PtxType::U32);
651                b.raw_ptx(&format!("mov.b32 {col_u32}, {col_i32};"));
652
653                let v_addr = b.byte_offset_addr(values_base.clone(), k.clone(), elem_bytes);
654                let val_half = b.alloc_reg(storage.ptx_type());
655                b.raw_ptx(&format!("ld.global.{storage_suffix} {val_half}, [{v_addr}];"));
656                let val_fp32 = b.alloc_reg(compute.ptx_type());
657                b.raw_ptx(&format!(
658                    "cvt.{compute_suffix}.{storage_suffix} {val_fp32}, {val_half};"
659                ));
660
661                let x_addr = b.byte_offset_addr(
662                    x_ptr.clone(),
663                    col_u32,
664                    compute.element_bytes(),
665                );
666                let x_val = b.load_global_f32(x_addr);
667
668                // FMA accumulate
669                let new_acc = b.fma_f32(val_fp32, x_val, acc.clone());
670                b.raw_ptx(&format!("mov.{compute_suffix} {acc}, {new_acc};"));
671
672                // k += 32
673                b.raw_ptx(&format!("add.u32 {k}, {k}, 32;"));
674                b.branch(&loop_label);
675                b.label(&done_label);
676
677                // Warp shuffle reduction in FP32
678                let mut current = acc;
679                for offset in [16u32, 8, 4, 2, 1] {
680                    let shuffled = b.alloc_reg(compute.ptx_type());
681                    b.raw_ptx(&format!(
682                        "shfl.sync.down.{compute_bit} {shuffled}, {current}, {offset}, 31, 0xFFFFFFFF;"
683                    ));
684                    let sum = b.alloc_reg(compute.ptx_type());
685                    b.raw_ptx(&format!(
686                        "add.{compute_suffix} {sum}, {current}, {shuffled};"
687                    ));
688                    current = sum;
689                }
690
691                // Lane 0 writes result
692                let is_lane_0 = b.alloc_reg(PtxType::Pred);
693                b.raw_ptx(&format!("setp.eq.u32 {is_lane_0}, {lane}, 0;"));
694
695                let skip_label = b.fresh_label("mpspmv_skip");
696                b.raw_ptx(&format!("@!{is_lane_0} bra {skip_label};"));
697
698                let y_addr = b.byte_offset_addr(y_ptr, row, compute.element_bytes());
699                let y_old = b.load_global_f32(y_addr.clone());
700
701                let alpha_acc = b.alloc_reg(compute.ptx_type());
702                b.raw_ptx(&format!(
703                    "mul.rn.{compute_suffix} {alpha_acc}, {alpha}, {current};"
704                ));
705                let beta_y = b.alloc_reg(compute.ptx_type());
706                b.raw_ptx(&format!(
707                    "mul.rn.{compute_suffix} {beta_y}, {beta}, {y_old};"
708                ));
709                let result = b.alloc_reg(compute.ptx_type());
710                b.raw_ptx(&format!(
711                    "add.{compute_suffix} {result}, {alpha_acc}, {beta_y};"
712                ));
713
714                b.store_global_f32(y_addr, result);
715
716                b.label(&skip_label);
717            });
718
719            b.ret();
720        })
721        .build()
722}
723
724// ---------------------------------------------------------------------------
725// PTX Generation: Packed Vector kernel (2xFP16 loads)
726// ---------------------------------------------------------------------------
727
728/// Generates PTX for packed vector mixed-precision SpMV.
729///
730/// This kernel loads two FP16 values per 32-bit memory transaction using
731/// the `f16x2` type, effectively doubling the bandwidth utilization for
732/// the values array. Each pair of FP16 values is unpacked, converted to
733/// FP32, and accumulated separately.
734///
735/// The kernel handles odd-length rows by processing the last element
736/// individually when the row has an odd number of non-zeros.
737///
738/// # Errors
739///
740/// Returns [`PtxGenError`] if kernel assembly fails.
741pub fn generate_packed_vector_spmv_ptx(
742    config: &MixedPrecisionConfig,
743) -> Result<String, PtxGenError> {
744    let storage = config.storage_precision;
745    let compute = config.compute_precision;
746    let sm = config.sm_version;
747    let storage_suffix = storage.suffix();
748    let compute_suffix = compute.suffix();
749    let compute_bit = compute.bit_suffix();
750    let elem_bytes = storage.element_bytes();
751
752    KernelBuilder::new("mixed_spmv_packed")
753        .target(sm)
754        .param("row_ptr", PtxType::U64)
755        .param("col_idx", PtxType::U64)
756        .param("values", PtxType::U64)
757        .param("x_ptr", PtxType::U64)
758        .param("y_ptr", PtxType::U64)
759        .param("alpha_bits", PtxType::U32)
760        .param("beta_bits", PtxType::U32)
761        .param("num_rows", PtxType::U32)
762        .body(move |b| {
763            let tid_global = b.global_thread_id_x();
764            let num_rows = b.load_param_u32("num_rows");
765
766            let lane = b.alloc_reg(PtxType::U32);
767            b.raw_ptx(&format!("and.b32 {lane}, {tid_global}, 31;"));
768
769            let warp_id = b.alloc_reg(PtxType::U32);
770            b.raw_ptx(&format!("shr.u32 {warp_id}, {tid_global}, 5;"));
771
772            let warp_id_inner = warp_id.clone();
773            let lane_inner = lane.clone();
774            b.if_lt_u32(warp_id, num_rows, move |b| {
775                let row = warp_id_inner;
776                let lane = lane_inner;
777
778                let row_ptr_base = b.load_param_u64("row_ptr");
779                let col_idx_base = b.load_param_u64("col_idx");
780                let values_base = b.load_param_u64("values");
781                let x_ptr = b.load_param_u64("x_ptr");
782                let y_ptr = b.load_param_u64("y_ptr");
783
784                let alpha_bits = b.load_param_u32("alpha_bits");
785                let alpha = b.alloc_reg(compute.ptx_type());
786                b.raw_ptx(&format!("mov.{compute_bit} {alpha}, {alpha_bits};"));
787
788                let beta_bits = b.load_param_u32("beta_bits");
789                let beta = b.alloc_reg(compute.ptx_type());
790                b.raw_ptx(&format!("mov.{compute_bit} {beta}, {beta_bits};"));
791
792                // Load row bounds
793                let rp_addr = b.byte_offset_addr(row_ptr_base.clone(), row.clone(), 4);
794                let row_start_i32 = b.load_global_i32(rp_addr);
795                let row_start = b.alloc_reg(PtxType::U32);
796                b.raw_ptx(&format!("mov.b32 {row_start}, {row_start_i32};"));
797
798                let row_plus_1 = b.alloc_reg(PtxType::U32);
799                b.raw_ptx(&format!("add.u32 {row_plus_1}, {row}, 1;"));
800                let rp_addr_next = b.byte_offset_addr(row_ptr_base, row_plus_1, 4);
801                let row_end_i32 = b.load_global_i32(rp_addr_next);
802                let row_end = b.alloc_reg(PtxType::U32);
803                b.raw_ptx(&format!("mov.b32 {row_end}, {row_end_i32};"));
804
805                // Compute nnz for this row and the "paired" end (rounded down to even)
806                let nnz_row = b.alloc_reg(PtxType::U32);
807                b.raw_ptx(&format!("sub.u32 {nnz_row}, {row_end}, {row_start};"));
808                let nnz_even = b.alloc_reg(PtxType::U32);
809                b.raw_ptx(&format!("and.b32 {nnz_even}, {nnz_row}, 0xFFFFFFFE;"));
810                let row_end_even = b.alloc_reg(PtxType::U32);
811                b.raw_ptx(&format!("add.u32 {row_end_even}, {row_start}, {nnz_even};"));
812
813                // Accumulator
814                let acc = b.alloc_reg(compute.ptx_type());
815                let zero_bits: u32 = 0u32;
816                b.raw_ptx(&format!("mov.{compute_bit} {acc}, 0F{zero_bits:08X};"));
817
818                // Packed loop: each lane processes pairs, stride = 32*2 = 64 elements
819                // Lane processes element indices: row_start + lane*2, row_start + lane*2 + 64, ...
820                let k = b.alloc_reg(PtxType::U32);
821                let lane_x2 = b.alloc_reg(PtxType::U32);
822                b.raw_ptx(&format!("shl.b32 {lane_x2}, {lane}, 1;"));
823                b.raw_ptx(&format!("add.u32 {k}, {row_start}, {lane_x2};"));
824
825                let packed_loop = b.fresh_label("mpspmv_packed_loop");
826                let packed_done = b.fresh_label("mpspmv_packed_done");
827
828                b.label(&packed_loop);
829                let pred_pair = b.alloc_reg(PtxType::Pred);
830                // k+1 must be < row_end_even to process a full pair
831                let k_plus_1 = b.alloc_reg(PtxType::U32);
832                b.raw_ptx(&format!("add.u32 {k_plus_1}, {k}, 1;"));
833                b.raw_ptx(&format!("setp.ls.u32 {pred_pair}, {k_plus_1}, {row_end_even};"));
834                b.raw_ptx(&format!("@!{pred_pair} bra {packed_done};"));
835
836                // Load packed 2xFP16 as a 32-bit value
837                // Address = values_base + k * 2 (each FP16 is 2 bytes)
838                let v_addr = b.byte_offset_addr(values_base.clone(), k.clone(), elem_bytes);
839                let packed_val = b.alloc_reg(PtxType::B32);
840                b.raw_ptx(&format!("ld.global.b32 {packed_val}, [{v_addr}];"));
841
842                // Unpack low half (first FP16)
843                let val_lo = b.alloc_reg(storage.ptx_type());
844                b.raw_ptx(&format!("mov.b16 {val_lo}, {{0}}; // placeholder"));
845                // Extract low 16 bits
846                let lo_bits = b.alloc_reg(PtxType::B16);
847                b.raw_ptx("{ .reg .b16 __hi;");
848                b.raw_ptx(&format!("mov.b32 {{{lo_bits}, __hi}}, {packed_val}; }}"));
849                let val_lo_h = b.alloc_reg(storage.ptx_type());
850                b.raw_ptx(&format!("mov.b16 {val_lo_h}, {lo_bits};"));
851                let val_lo_f32 = b.alloc_reg(compute.ptx_type());
852                b.raw_ptx(&format!(
853                    "cvt.{compute_suffix}.{storage_suffix} {val_lo_f32}, {val_lo_h};"
854                ));
855
856                // Extract high 16 bits
857                let hi_bits = b.alloc_reg(PtxType::B16);
858                b.raw_ptx("{ .reg .b16 __lo;");
859                b.raw_ptx(&format!("mov.b32 {{__lo, {hi_bits}}}, {packed_val}; }}"));
860                let val_hi_h = b.alloc_reg(storage.ptx_type());
861                b.raw_ptx(&format!("mov.b16 {val_hi_h}, {hi_bits};"));
862                let val_hi_f32 = b.alloc_reg(compute.ptx_type());
863                b.raw_ptx(&format!(
864                    "cvt.{compute_suffix}.{storage_suffix} {val_hi_f32}, {val_hi_h};"
865                ));
866
867                // Load two column indices and x values
868                let ci_addr_0 = b.byte_offset_addr(col_idx_base.clone(), k.clone(), 4);
869                let col_0_i32 = b.load_global_i32(ci_addr_0);
870                let col_0 = b.alloc_reg(PtxType::U32);
871                b.raw_ptx(&format!("mov.b32 {col_0}, {col_0_i32};"));
872
873                let ci_addr_1 = b.byte_offset_addr(col_idx_base.clone(), k_plus_1.clone(), 4);
874                let col_1_i32 = b.load_global_i32(ci_addr_1);
875                let col_1 = b.alloc_reg(PtxType::U32);
876                b.raw_ptx(&format!("mov.b32 {col_1}, {col_1_i32};"));
877
878                let x_addr_0 = b.byte_offset_addr(x_ptr.clone(), col_0, compute.element_bytes());
879                let x_val_0 = b.load_global_f32(x_addr_0);
880
881                let x_addr_1 = b.byte_offset_addr(x_ptr.clone(), col_1, compute.element_bytes());
882                let x_val_1 = b.load_global_f32(x_addr_1);
883
884                // FMA: acc += val_lo * x0; acc += val_hi * x1
885                let acc1 = b.fma_f32(val_lo_f32, x_val_0, acc.clone());
886                b.raw_ptx(&format!("mov.{compute_suffix} {acc}, {acc1};"));
887                let acc2 = b.fma_f32(val_hi_f32, x_val_1, acc.clone());
888                b.raw_ptx(&format!("mov.{compute_suffix} {acc}, {acc2};"));
889
890                // k += 64 (32 lanes * 2 elements per lane)
891                b.raw_ptx(&format!("add.u32 {k}, {k}, 64;"));
892                b.branch(&packed_loop);
893                b.label(&packed_done);
894
895                // Handle remainder: if nnz is odd, one element is left unpaired
896                let has_remainder = b.alloc_reg(PtxType::Pred);
897                let nnz_odd = b.alloc_reg(PtxType::U32);
898                b.raw_ptx(&format!("and.b32 {nnz_odd}, {nnz_row}, 1;"));
899                b.raw_ptx(&format!("setp.ne.u32 {has_remainder}, {nnz_odd}, 0;"));
900
901                let remainder_done = b.fresh_label("mpspmv_rem_done");
902                b.raw_ptx(&format!("@!{has_remainder} bra {remainder_done};"));
903
904                // Only lane 0 handles the remainder element
905                let is_lane_0_rem = b.alloc_reg(PtxType::Pred);
906                b.raw_ptx(&format!("setp.eq.u32 {is_lane_0_rem}, {lane}, 0;"));
907                b.raw_ptx(&format!("@!{is_lane_0_rem} bra {remainder_done};"));
908
909                // Last element index = row_end - 1
910                let last_idx = b.alloc_reg(PtxType::U32);
911                b.raw_ptx(&format!("sub.u32 {last_idx}, {row_end}, 1;"));
912
913                let last_v_addr =
914                    b.byte_offset_addr(values_base.clone(), last_idx.clone(), elem_bytes);
915                let last_val_h = b.alloc_reg(storage.ptx_type());
916                b.raw_ptx(&format!(
917                    "ld.global.{storage_suffix} {last_val_h}, [{last_v_addr}];"
918                ));
919                let last_val_f32 = b.alloc_reg(compute.ptx_type());
920                b.raw_ptx(&format!(
921                    "cvt.{compute_suffix}.{storage_suffix} {last_val_f32}, {last_val_h};"
922                ));
923
924                let last_ci_addr = b.byte_offset_addr(col_idx_base, last_idx, 4);
925                let last_col_i32 = b.load_global_i32(last_ci_addr);
926                let last_col = b.alloc_reg(PtxType::U32);
927                b.raw_ptx(&format!("mov.b32 {last_col}, {last_col_i32};"));
928
929                let last_x_addr =
930                    b.byte_offset_addr(x_ptr, last_col, compute.element_bytes());
931                let last_x_val = b.load_global_f32(last_x_addr);
932
933                let acc_rem = b.fma_f32(last_val_f32, last_x_val, acc.clone());
934                b.raw_ptx(&format!("mov.{compute_suffix} {acc}, {acc_rem};"));
935
936                b.label(&remainder_done);
937
938                // Warp shuffle reduction in FP32
939                let mut current = acc;
940                for offset in [16u32, 8, 4, 2, 1] {
941                    let shuffled = b.alloc_reg(compute.ptx_type());
942                    b.raw_ptx(&format!(
943                        "shfl.sync.down.{compute_bit} {shuffled}, {current}, {offset}, 31, 0xFFFFFFFF;"
944                    ));
945                    let sum = b.alloc_reg(compute.ptx_type());
946                    b.raw_ptx(&format!(
947                        "add.{compute_suffix} {sum}, {current}, {shuffled};"
948                    ));
949                    current = sum;
950                }
951
952                // Lane 0 writes the final result
953                let is_lane_0 = b.alloc_reg(PtxType::Pred);
954                b.raw_ptx(&format!("setp.eq.u32 {is_lane_0}, {lane}, 0;"));
955
956                let skip_label = b.fresh_label("mpspmv_pskip");
957                b.raw_ptx(&format!("@!{is_lane_0} bra {skip_label};"));
958
959                let y_addr = b.byte_offset_addr(y_ptr, row, compute.element_bytes());
960                let y_old = b.load_global_f32(y_addr.clone());
961
962                let alpha_acc = b.alloc_reg(compute.ptx_type());
963                b.raw_ptx(&format!(
964                    "mul.rn.{compute_suffix} {alpha_acc}, {alpha}, {current};"
965                ));
966                let beta_y = b.alloc_reg(compute.ptx_type());
967                b.raw_ptx(&format!(
968                    "mul.rn.{compute_suffix} {beta_y}, {beta}, {y_old};"
969                ));
970                let result = b.alloc_reg(compute.ptx_type());
971                b.raw_ptx(&format!(
972                    "add.{compute_suffix} {result}, {alpha_acc}, {beta_y};"
973                ));
974
975                b.store_global_f32(y_addr, result);
976
977                b.label(&skip_label);
978            });
979
980            b.ret();
981        })
982        .build()
983}
984
985// ---------------------------------------------------------------------------
986// Helper: resolve block/grid sizes
987// ---------------------------------------------------------------------------
988
989/// Returns `(grid_size, block_size)` for a scalar kernel.
990#[must_use]
991pub fn scalar_launch_params(num_rows: u32) -> (u32, u32) {
992    let block = SCALAR_BLOCK_SIZE;
993    let grid = num_rows.div_ceil(block);
994    (grid, block)
995}
996
997/// Returns `(grid_size, block_size)` for a vector/packed kernel.
998#[must_use]
999pub fn vector_launch_params(num_rows: u32) -> (u32, u32) {
1000    let block = VECTOR_BLOCK_SIZE;
1001    let warps_per_block = block / 32;
1002    let grid = num_rows.div_ceil(warps_per_block);
1003    (grid, block)
1004}
1005
1006// ---------------------------------------------------------------------------
1007// Tests
1008// ---------------------------------------------------------------------------
1009
1010#[cfg(test)]
1011mod tests {
1012    use super::*;
1013
1014    fn default_fp16_config(algo: MixedSpMVAlgo) -> MixedPrecisionConfig {
1015        MixedPrecisionConfig::fp16_fp32(algo, SmVersion::Sm80)
1016    }
1017
1018    fn default_bf16_config(algo: MixedSpMVAlgo) -> MixedPrecisionConfig {
1019        MixedPrecisionConfig::bf16_fp32(algo, SmVersion::Sm80)
1020    }
1021
1022    // --- PTX Generation tests ---
1023
1024    #[test]
1025    fn scalar_ptx_fp16_generates() {
1026        let config = default_fp16_config(MixedSpMVAlgo::Scalar);
1027        let ptx = generate_mixed_scalar_spmv_ptx(&config);
1028        assert!(ptx.is_ok(), "PTX generation failed: {ptx:?}");
1029        let ptx = ptx.expect("test");
1030        assert!(ptx.contains(".entry mixed_spmv_scalar"));
1031        assert!(ptx.contains(".target sm_80"));
1032        // Should contain FP16 -> FP32 conversion
1033        assert!(ptx.contains("cvt.f32.f16"));
1034    }
1035
1036    #[test]
1037    fn scalar_ptx_bf16_generates() {
1038        let config = default_bf16_config(MixedSpMVAlgo::Scalar);
1039        let ptx = generate_mixed_scalar_spmv_ptx(&config);
1040        assert!(ptx.is_ok(), "PTX generation failed: {ptx:?}");
1041        let ptx = ptx.expect("test");
1042        assert!(ptx.contains("cvt.f32.bf16"));
1043    }
1044
1045    #[test]
1046    fn vector_ptx_fp16_generates() {
1047        let config = default_fp16_config(MixedSpMVAlgo::Vector);
1048        let ptx = generate_mixed_vector_spmv_ptx(&config);
1049        assert!(ptx.is_ok(), "PTX generation failed: {ptx:?}");
1050        let ptx = ptx.expect("test");
1051        assert!(ptx.contains(".entry mixed_spmv_vector"));
1052        assert!(ptx.contains("shfl.sync.down"));
1053        assert!(ptx.contains("cvt.f32.f16"));
1054    }
1055
1056    #[test]
1057    fn vector_ptx_bf16_generates() {
1058        let config = default_bf16_config(MixedSpMVAlgo::Vector);
1059        let ptx = generate_mixed_vector_spmv_ptx(&config);
1060        assert!(ptx.is_ok(), "PTX generation failed: {ptx:?}");
1061        let ptx = ptx.expect("test");
1062        assert!(ptx.contains("cvt.f32.bf16"));
1063        assert!(ptx.contains("shfl.sync.down"));
1064    }
1065
1066    #[test]
1067    fn packed_ptx_fp16_generates() {
1068        let config = default_fp16_config(MixedSpMVAlgo::VectorPacked);
1069        let ptx = generate_packed_vector_spmv_ptx(&config);
1070        assert!(ptx.is_ok(), "PTX generation failed: {ptx:?}");
1071        let ptx = ptx.expect("test");
1072        assert!(ptx.contains(".entry mixed_spmv_packed"));
1073        // Should use 32-bit loads for packed FP16
1074        assert!(ptx.contains("ld.global.b32"));
1075        assert!(ptx.contains("shfl.sync.down"));
1076    }
1077
1078    #[test]
1079    fn packed_ptx_bf16_generates() {
1080        let config = default_bf16_config(MixedSpMVAlgo::VectorPacked);
1081        let ptx = generate_packed_vector_spmv_ptx(&config);
1082        assert!(ptx.is_ok(), "PTX generation failed: {ptx:?}");
1083        let ptx = ptx.expect("test");
1084        assert!(ptx.contains("cvt.f32.bf16"));
1085    }
1086
1087    // --- Config validation tests ---
1088
1089    #[test]
1090    fn validate_fp16_on_turing() {
1091        let config = MixedPrecisionConfig::fp16_fp32(MixedSpMVAlgo::Scalar, SmVersion::Sm75);
1092        assert!(validate_mixed_precision_config(&config).is_ok());
1093    }
1094
1095    #[test]
1096    fn validate_bf16_on_turing_fails() {
1097        let config = MixedPrecisionConfig::bf16_fp32(MixedSpMVAlgo::Scalar, SmVersion::Sm75);
1098        let result = validate_mixed_precision_config(&config);
1099        assert!(result.is_err());
1100        let err_msg = format!("{}", result.expect_err("test"));
1101        assert!(err_msg.contains("BF16"));
1102    }
1103
1104    #[test]
1105    fn validate_bf16_on_ampere_ok() {
1106        let config = MixedPrecisionConfig::bf16_fp32(MixedSpMVAlgo::Vector, SmVersion::Sm80);
1107        assert!(validate_mixed_precision_config(&config).is_ok());
1108    }
1109
1110    // --- Plan tests ---
1111
1112    #[test]
1113    fn plan_auto_selects_scalar_for_sparse() {
1114        let config = default_fp16_config(MixedSpMVAlgo::Auto);
1115        // 1000 rows, 2000 nnz => avg 2 nnz/row => should select Scalar
1116        let plan = plan_mixed_precision_spmv(&config, 1000, 5000, 2000);
1117        assert!(plan.is_ok());
1118        let plan = plan.expect("test");
1119        assert_eq!(plan.config.algorithm, MixedSpMVAlgo::Scalar);
1120    }
1121
1122    #[test]
1123    fn plan_auto_selects_vector_for_moderate() {
1124        let config = default_fp16_config(MixedSpMVAlgo::Auto);
1125        // 1000 rows, 10000 nnz => avg 10 nnz/row => should select Vector
1126        let plan = plan_mixed_precision_spmv(&config, 1000, 5000, 10000);
1127        assert!(plan.is_ok());
1128        let plan = plan.expect("test");
1129        assert_eq!(plan.config.algorithm, MixedSpMVAlgo::Vector);
1130    }
1131
1132    #[test]
1133    fn plan_auto_selects_packed_for_dense() {
1134        let config = default_fp16_config(MixedSpMVAlgo::Auto);
1135        // 1000 rows, 50000 nnz => avg 50 nnz/row => should select VectorPacked
1136        let plan = plan_mixed_precision_spmv(&config, 1000, 5000, 50000);
1137        assert!(plan.is_ok());
1138        let plan = plan.expect("test");
1139        assert_eq!(plan.config.algorithm, MixedSpMVAlgo::VectorPacked);
1140    }
1141
1142    // --- Bandwidth estimation tests ---
1143
1144    #[test]
1145    fn bandwidth_savings_fp16_vs_fp32() {
1146        let config = default_fp16_config(MixedSpMVAlgo::Scalar);
1147        let plan = plan_mixed_precision_spmv(&config, 1000, 1000, 10000).expect("test");
1148        // FP16 = 2 bytes, FP32 = 4 bytes => ratio = 2.0
1149        let ratio = plan.bandwidth_savings_ratio();
1150        assert!((ratio - 2.0).abs() < 1e-10, "Expected ~2.0, got {ratio}");
1151    }
1152
1153    #[test]
1154    fn estimated_gflops_positive() {
1155        let config = default_fp16_config(MixedSpMVAlgo::Vector);
1156        let plan = plan_mixed_precision_spmv(&config, 10000, 10000, 100000).expect("test");
1157        // With 1000 GB/s peak bandwidth (A100-like)
1158        let gflops = plan.estimated_gflops(1000.0);
1159        assert!(gflops > 0.0, "Expected positive GFLOPS, got {gflops}");
1160        // Sanity: should be in a reasonable range for roofline
1161        assert!(gflops < 1000.0, "GFLOPS suspiciously high: {gflops}");
1162    }
1163
1164    // --- Precision loss estimation tests ---
1165
1166    #[test]
1167    fn precision_loss_fp16_bounded() {
1168        let loss = estimate_precision_loss(100.0, StoragePrecision::Fp16);
1169        // eps_fp16 = 2^-11 ~= 4.88e-4, eps_fp32 = 2^-24 ~= 5.96e-8
1170        // bound = 100 * (4.88e-4 + 5.96e-8) ~ 0.0488
1171        assert!(loss > 0.0);
1172        assert!(loss < 0.1, "Precision loss bound too large: {loss}");
1173    }
1174
1175    #[test]
1176    fn precision_loss_bf16_larger_than_fp16() {
1177        let loss_fp16 = estimate_precision_loss(50.0, StoragePrecision::Fp16);
1178        let loss_bf16 = estimate_precision_loss(50.0, StoragePrecision::Bf16);
1179        // BF16 has fewer mantissa bits, so larger error bound
1180        assert!(
1181            loss_bf16 > loss_fp16,
1182            "BF16 loss ({loss_bf16}) should exceed FP16 loss ({loss_fp16})"
1183        );
1184    }
1185
1186    // --- Launch parameter tests ---
1187
1188    #[test]
1189    fn scalar_launch_params_correct() {
1190        let (grid, block) = scalar_launch_params(1000);
1191        assert_eq!(block, 256);
1192        assert_eq!(grid, 4); // ceil(1000/256) = 4
1193    }
1194
1195    #[test]
1196    fn vector_launch_params_correct() {
1197        let (grid, block) = vector_launch_params(1000);
1198        assert_eq!(block, 256);
1199        // warps_per_block = 256/32 = 8, grid = ceil(1000/8) = 125
1200        assert_eq!(grid, 125);
1201    }
1202
1203    // --- Mixed-precision PTX instruction accuracy tests ---
1204
1205    /// Verify that scalar FP16→FP32 kernel contains `cvt.f32.f16` conversion.
1206    ///
1207    /// This ensures FP16 values are widened to FP32 before accumulation,
1208    /// which is the core requirement for mixed-precision SpMV numerical quality.
1209    #[test]
1210    fn mixed_precision_scalar_ptx_contains_fp16_to_fp32_conversion() {
1211        let config = MixedPrecisionConfig::fp16_fp32(MixedSpMVAlgo::Scalar, SmVersion::Sm80);
1212        let ptx = generate_mixed_scalar_spmv_ptx(&config);
1213        assert!(ptx.is_ok(), "PTX gen failed: {ptx:?}");
1214        let ptx = ptx.expect("test");
1215
1216        // Must contain FP16 → FP32 widening conversion
1217        assert!(
1218            ptx.contains("cvt.f32.f16"),
1219            "scalar kernel must contain cvt.f32.f16 for FP16→FP32 widening"
1220        );
1221    }
1222
1223    /// Verify that scalar FP16→FP32 kernel contains `fma.rn.f32` accumulation.
1224    ///
1225    /// The round-to-nearest FMA is required for correct FP32 accumulation quality.
1226    #[test]
1227    fn mixed_precision_scalar_ptx_contains_fma_rn_f32_accumulation() {
1228        let config = MixedPrecisionConfig::fp16_fp32(MixedSpMVAlgo::Scalar, SmVersion::Sm80);
1229        let ptx = generate_mixed_scalar_spmv_ptx(&config);
1230        assert!(ptx.is_ok(), "PTX gen failed: {ptx:?}");
1231        let ptx = ptx.expect("test");
1232
1233        // Must contain FP32 FMA with round-to-nearest mode for accuracy
1234        assert!(
1235            ptx.contains("fma.rn.f32"),
1236            "scalar kernel must contain fma.rn.f32 for FP32 accumulation"
1237        );
1238    }
1239
1240    /// Verify vector FP16→FP32 kernel contains both conversion and FMA instructions.
1241    #[test]
1242    fn mixed_precision_vector_ptx_contains_conversion_and_fma() {
1243        let config = MixedPrecisionConfig::fp16_fp32(MixedSpMVAlgo::Vector, SmVersion::Sm80);
1244        let ptx = generate_mixed_vector_spmv_ptx(&config);
1245        assert!(ptx.is_ok(), "PTX gen failed: {ptx:?}");
1246        let ptx = ptx.expect("test");
1247
1248        assert!(
1249            ptx.contains("cvt.f32.f16"),
1250            "vector kernel must contain cvt.f32.f16 for FP16→FP32 widening"
1251        );
1252        assert!(
1253            ptx.contains("fma.rn.f32"),
1254            "vector kernel must contain fma.rn.f32 for FP32 accumulation"
1255        );
1256    }
1257
1258    /// Verify BF16→FP32 kernel uses `cvt.f32.bf16` (not `cvt.f32.f16`).
1259    #[test]
1260    fn mixed_precision_bf16_ptx_uses_bf16_conversion() {
1261        let config = MixedPrecisionConfig::bf16_fp32(MixedSpMVAlgo::Scalar, SmVersion::Sm80);
1262        let ptx = generate_mixed_scalar_spmv_ptx(&config);
1263        assert!(ptx.is_ok(), "PTX gen failed: {ptx:?}");
1264        let ptx = ptx.expect("test");
1265
1266        assert!(
1267            ptx.contains("cvt.f32.bf16"),
1268            "BF16 kernel must contain cvt.f32.bf16 for BF16→FP32 widening"
1269        );
1270        // BF16 kernel should NOT use the FP16 conversion instruction
1271        assert!(
1272            !ptx.contains("cvt.f32.f16"),
1273            "BF16 kernel must NOT contain cvt.f32.f16"
1274        );
1275    }
1276
1277    /// Verify that `mul.rn.f32` is used for alpha/beta scaling (not just fma).
1278    #[test]
1279    fn mixed_precision_scalar_ptx_uses_rn_mode_for_scaling() {
1280        let config = MixedPrecisionConfig::fp16_fp32(MixedSpMVAlgo::Scalar, SmVersion::Sm80);
1281        let ptx = generate_mixed_scalar_spmv_ptx(&config);
1282        assert!(ptx.is_ok(), "PTX gen failed: {ptx:?}");
1283        let ptx = ptx.expect("test");
1284
1285        // The alpha/beta scaling uses mul.rn.f32
1286        assert!(
1287            ptx.contains("mul.rn.f32"),
1288            "scalar kernel must contain mul.rn.f32 for alpha/beta scaling"
1289        );
1290    }
1291
1292    /// Verify precision loss estimation is monotone: more nnz → larger error bound.
1293    #[test]
1294    fn precision_loss_monotone_in_nnz_per_row() {
1295        let nnz_values = [1.0_f64, 10.0, 100.0, 1000.0];
1296        let mut prev_loss = 0.0_f64;
1297        for &nnz in &nnz_values {
1298            let loss = estimate_precision_loss(nnz, StoragePrecision::Fp16);
1299            assert!(
1300                loss >= prev_loss,
1301                "precision loss should increase with nnz/row: nnz={nnz}, loss={loss}, prev={prev_loss}"
1302            );
1303            prev_loss = loss;
1304        }
1305    }
1306
1307    /// Verify that the precision loss is proportional to nnz/row.
1308    ///
1309    /// From the formula: loss = nnz * (eps_storage + eps_compute),
1310    /// so doubling nnz should double the loss.
1311    #[test]
1312    fn precision_loss_linear_in_nnz_per_row() {
1313        let loss_10 = estimate_precision_loss(10.0, StoragePrecision::Fp16);
1314        let loss_20 = estimate_precision_loss(20.0, StoragePrecision::Fp16);
1315
1316        // loss_20 should be exactly 2 * loss_10 (linear in nnz)
1317        assert!(
1318            (loss_20 - 2.0 * loss_10).abs() < 1e-15,
1319            "Precision loss should be linear: loss(20)={loss_20} should be 2*loss(10)={loss_10}"
1320        );
1321    }
1322
1323    /// Verify that the bandwidth savings ratio for BF16 is also ~2x vs FP32.
1324    #[test]
1325    fn bandwidth_savings_bf16_vs_fp32() {
1326        let config = default_bf16_config(MixedSpMVAlgo::Scalar);
1327        let plan = plan_mixed_precision_spmv(&config, 1000, 1000, 10000).expect("test");
1328        // BF16 = 2 bytes, FP32 = 4 bytes => ratio = 2.0
1329        let ratio = plan.bandwidth_savings_ratio();
1330        assert!(
1331            (ratio - 2.0).abs() < 1e-10,
1332            "BF16 bandwidth savings ratio should be ~2.0, got {ratio}"
1333        );
1334    }
1335
1336    /// Verify the avg_nnz_per_row calculation is correct.
1337    #[test]
1338    fn mixed_plan_avg_nnz_per_row_calculation() {
1339        let config = default_fp16_config(MixedSpMVAlgo::Scalar);
1340        // 100 rows, 500 nnz => avg = 5.0
1341        let plan = plan_mixed_precision_spmv(&config, 100, 1000, 500).expect("test");
1342        let avg = plan.avg_nnz_per_row();
1343        assert!(
1344            (avg - 5.0).abs() < 1e-10,
1345            "avg_nnz_per_row should be 5.0, got {avg}"
1346        );
1347    }
1348}