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                // Extract low 16 bits
844                let lo_bits = b.alloc_reg(PtxType::B16);
845                b.raw_ptx("{ .reg .b16 __hi;");
846                b.raw_ptx(&format!("mov.b32 {{{lo_bits}, __hi}}, {packed_val}; }}"));
847                let val_lo_h = b.alloc_reg(storage.ptx_type());
848                b.raw_ptx(&format!("mov.b16 {val_lo_h}, {lo_bits};"));
849                let val_lo_f32 = b.alloc_reg(compute.ptx_type());
850                b.raw_ptx(&format!(
851                    "cvt.{compute_suffix}.{storage_suffix} {val_lo_f32}, {val_lo_h};"
852                ));
853
854                // Extract high 16 bits
855                let hi_bits = b.alloc_reg(PtxType::B16);
856                b.raw_ptx("{ .reg .b16 __lo;");
857                b.raw_ptx(&format!("mov.b32 {{__lo, {hi_bits}}}, {packed_val}; }}"));
858                let val_hi_h = b.alloc_reg(storage.ptx_type());
859                b.raw_ptx(&format!("mov.b16 {val_hi_h}, {hi_bits};"));
860                let val_hi_f32 = b.alloc_reg(compute.ptx_type());
861                b.raw_ptx(&format!(
862                    "cvt.{compute_suffix}.{storage_suffix} {val_hi_f32}, {val_hi_h};"
863                ));
864
865                // Load two column indices and x values
866                let ci_addr_0 = b.byte_offset_addr(col_idx_base.clone(), k.clone(), 4);
867                let col_0_i32 = b.load_global_i32(ci_addr_0);
868                let col_0 = b.alloc_reg(PtxType::U32);
869                b.raw_ptx(&format!("mov.b32 {col_0}, {col_0_i32};"));
870
871                let ci_addr_1 = b.byte_offset_addr(col_idx_base.clone(), k_plus_1.clone(), 4);
872                let col_1_i32 = b.load_global_i32(ci_addr_1);
873                let col_1 = b.alloc_reg(PtxType::U32);
874                b.raw_ptx(&format!("mov.b32 {col_1}, {col_1_i32};"));
875
876                let x_addr_0 = b.byte_offset_addr(x_ptr.clone(), col_0, compute.element_bytes());
877                let x_val_0 = b.load_global_f32(x_addr_0);
878
879                let x_addr_1 = b.byte_offset_addr(x_ptr.clone(), col_1, compute.element_bytes());
880                let x_val_1 = b.load_global_f32(x_addr_1);
881
882                // FMA: acc += val_lo * x0; acc += val_hi * x1
883                let acc1 = b.fma_f32(val_lo_f32, x_val_0, acc.clone());
884                b.raw_ptx(&format!("mov.{compute_suffix} {acc}, {acc1};"));
885                let acc2 = b.fma_f32(val_hi_f32, x_val_1, acc.clone());
886                b.raw_ptx(&format!("mov.{compute_suffix} {acc}, {acc2};"));
887
888                // k += 64 (32 lanes * 2 elements per lane)
889                b.raw_ptx(&format!("add.u32 {k}, {k}, 64;"));
890                b.branch(&packed_loop);
891                b.label(&packed_done);
892
893                // Handle remainder: if nnz is odd, one element is left unpaired
894                let has_remainder = b.alloc_reg(PtxType::Pred);
895                let nnz_odd = b.alloc_reg(PtxType::U32);
896                b.raw_ptx(&format!("and.b32 {nnz_odd}, {nnz_row}, 1;"));
897                b.raw_ptx(&format!("setp.ne.u32 {has_remainder}, {nnz_odd}, 0;"));
898
899                let remainder_done = b.fresh_label("mpspmv_rem_done");
900                b.raw_ptx(&format!("@!{has_remainder} bra {remainder_done};"));
901
902                // Only lane 0 handles the remainder element
903                let is_lane_0_rem = b.alloc_reg(PtxType::Pred);
904                b.raw_ptx(&format!("setp.eq.u32 {is_lane_0_rem}, {lane}, 0;"));
905                b.raw_ptx(&format!("@!{is_lane_0_rem} bra {remainder_done};"));
906
907                // Last element index = row_end - 1
908                let last_idx = b.alloc_reg(PtxType::U32);
909                b.raw_ptx(&format!("sub.u32 {last_idx}, {row_end}, 1;"));
910
911                let last_v_addr =
912                    b.byte_offset_addr(values_base.clone(), last_idx.clone(), elem_bytes);
913                let last_val_h = b.alloc_reg(storage.ptx_type());
914                b.raw_ptx(&format!(
915                    "ld.global.{storage_suffix} {last_val_h}, [{last_v_addr}];"
916                ));
917                let last_val_f32 = b.alloc_reg(compute.ptx_type());
918                b.raw_ptx(&format!(
919                    "cvt.{compute_suffix}.{storage_suffix} {last_val_f32}, {last_val_h};"
920                ));
921
922                let last_ci_addr = b.byte_offset_addr(col_idx_base, last_idx, 4);
923                let last_col_i32 = b.load_global_i32(last_ci_addr);
924                let last_col = b.alloc_reg(PtxType::U32);
925                b.raw_ptx(&format!("mov.b32 {last_col}, {last_col_i32};"));
926
927                let last_x_addr =
928                    b.byte_offset_addr(x_ptr, last_col, compute.element_bytes());
929                let last_x_val = b.load_global_f32(last_x_addr);
930
931                let acc_rem = b.fma_f32(last_val_f32, last_x_val, acc.clone());
932                b.raw_ptx(&format!("mov.{compute_suffix} {acc}, {acc_rem};"));
933
934                b.label(&remainder_done);
935
936                // Warp shuffle reduction in FP32
937                let mut current = acc;
938                for offset in [16u32, 8, 4, 2, 1] {
939                    let shuffled = b.alloc_reg(compute.ptx_type());
940                    b.raw_ptx(&format!(
941                        "shfl.sync.down.{compute_bit} {shuffled}, {current}, {offset}, 31, 0xFFFFFFFF;"
942                    ));
943                    let sum = b.alloc_reg(compute.ptx_type());
944                    b.raw_ptx(&format!(
945                        "add.{compute_suffix} {sum}, {current}, {shuffled};"
946                    ));
947                    current = sum;
948                }
949
950                // Lane 0 writes the final result
951                let is_lane_0 = b.alloc_reg(PtxType::Pred);
952                b.raw_ptx(&format!("setp.eq.u32 {is_lane_0}, {lane}, 0;"));
953
954                let skip_label = b.fresh_label("mpspmv_pskip");
955                b.raw_ptx(&format!("@!{is_lane_0} bra {skip_label};"));
956
957                let y_addr = b.byte_offset_addr(y_ptr, row, compute.element_bytes());
958                let y_old = b.load_global_f32(y_addr.clone());
959
960                let alpha_acc = b.alloc_reg(compute.ptx_type());
961                b.raw_ptx(&format!(
962                    "mul.rn.{compute_suffix} {alpha_acc}, {alpha}, {current};"
963                ));
964                let beta_y = b.alloc_reg(compute.ptx_type());
965                b.raw_ptx(&format!(
966                    "mul.rn.{compute_suffix} {beta_y}, {beta}, {y_old};"
967                ));
968                let result = b.alloc_reg(compute.ptx_type());
969                b.raw_ptx(&format!(
970                    "add.{compute_suffix} {result}, {alpha_acc}, {beta_y};"
971                ));
972
973                b.store_global_f32(y_addr, result);
974
975                b.label(&skip_label);
976            });
977
978            b.ret();
979        })
980        .build()
981}
982
983// ---------------------------------------------------------------------------
984// Helper: resolve block/grid sizes
985// ---------------------------------------------------------------------------
986
987/// Returns `(grid_size, block_size)` for a scalar kernel.
988#[must_use]
989pub fn scalar_launch_params(num_rows: u32) -> (u32, u32) {
990    let block = SCALAR_BLOCK_SIZE;
991    let grid = num_rows.div_ceil(block);
992    (grid, block)
993}
994
995/// Returns `(grid_size, block_size)` for a vector/packed kernel.
996#[must_use]
997pub fn vector_launch_params(num_rows: u32) -> (u32, u32) {
998    let block = VECTOR_BLOCK_SIZE;
999    let warps_per_block = block / 32;
1000    let grid = num_rows.div_ceil(warps_per_block);
1001    (grid, block)
1002}
1003
1004// ---------------------------------------------------------------------------
1005// Tests
1006// ---------------------------------------------------------------------------
1007
1008#[cfg(test)]
1009mod tests {
1010    use super::*;
1011
1012    fn default_fp16_config(algo: MixedSpMVAlgo) -> MixedPrecisionConfig {
1013        MixedPrecisionConfig::fp16_fp32(algo, SmVersion::Sm80)
1014    }
1015
1016    fn default_bf16_config(algo: MixedSpMVAlgo) -> MixedPrecisionConfig {
1017        MixedPrecisionConfig::bf16_fp32(algo, SmVersion::Sm80)
1018    }
1019
1020    // --- PTX Generation tests ---
1021
1022    #[test]
1023    fn scalar_ptx_fp16_generates() {
1024        let config = default_fp16_config(MixedSpMVAlgo::Scalar);
1025        let ptx = generate_mixed_scalar_spmv_ptx(&config);
1026        assert!(ptx.is_ok(), "PTX generation failed: {ptx:?}");
1027        let ptx = ptx.expect("test");
1028        assert!(ptx.contains(".entry mixed_spmv_scalar"));
1029        assert!(ptx.contains(".target sm_80"));
1030        // Should contain FP16 -> FP32 conversion
1031        assert!(ptx.contains("cvt.f32.f16"));
1032    }
1033
1034    #[test]
1035    fn scalar_ptx_bf16_generates() {
1036        let config = default_bf16_config(MixedSpMVAlgo::Scalar);
1037        let ptx = generate_mixed_scalar_spmv_ptx(&config);
1038        assert!(ptx.is_ok(), "PTX generation failed: {ptx:?}");
1039        let ptx = ptx.expect("test");
1040        assert!(ptx.contains("cvt.f32.bf16"));
1041    }
1042
1043    #[test]
1044    fn vector_ptx_fp16_generates() {
1045        let config = default_fp16_config(MixedSpMVAlgo::Vector);
1046        let ptx = generate_mixed_vector_spmv_ptx(&config);
1047        assert!(ptx.is_ok(), "PTX generation failed: {ptx:?}");
1048        let ptx = ptx.expect("test");
1049        assert!(ptx.contains(".entry mixed_spmv_vector"));
1050        assert!(ptx.contains("shfl.sync.down"));
1051        assert!(ptx.contains("cvt.f32.f16"));
1052    }
1053
1054    #[test]
1055    fn vector_ptx_bf16_generates() {
1056        let config = default_bf16_config(MixedSpMVAlgo::Vector);
1057        let ptx = generate_mixed_vector_spmv_ptx(&config);
1058        assert!(ptx.is_ok(), "PTX generation failed: {ptx:?}");
1059        let ptx = ptx.expect("test");
1060        assert!(ptx.contains("cvt.f32.bf16"));
1061        assert!(ptx.contains("shfl.sync.down"));
1062    }
1063
1064    #[test]
1065    fn packed_ptx_fp16_generates() {
1066        let config = default_fp16_config(MixedSpMVAlgo::VectorPacked);
1067        let ptx = generate_packed_vector_spmv_ptx(&config);
1068        assert!(ptx.is_ok(), "PTX generation failed: {ptx:?}");
1069        let ptx = ptx.expect("test");
1070        assert!(ptx.contains(".entry mixed_spmv_packed"));
1071        // Should use 32-bit loads for packed FP16
1072        assert!(ptx.contains("ld.global.b32"));
1073        assert!(ptx.contains("shfl.sync.down"));
1074    }
1075
1076    #[test]
1077    fn packed_ptx_bf16_generates() {
1078        let config = default_bf16_config(MixedSpMVAlgo::VectorPacked);
1079        let ptx = generate_packed_vector_spmv_ptx(&config);
1080        assert!(ptx.is_ok(), "PTX generation failed: {ptx:?}");
1081        let ptx = ptx.expect("test");
1082        assert!(ptx.contains("cvt.f32.bf16"));
1083    }
1084
1085    // --- Config validation tests ---
1086
1087    #[test]
1088    fn validate_fp16_on_turing() {
1089        let config = MixedPrecisionConfig::fp16_fp32(MixedSpMVAlgo::Scalar, SmVersion::Sm75);
1090        assert!(validate_mixed_precision_config(&config).is_ok());
1091    }
1092
1093    #[test]
1094    fn validate_bf16_on_turing_fails() {
1095        let config = MixedPrecisionConfig::bf16_fp32(MixedSpMVAlgo::Scalar, SmVersion::Sm75);
1096        let result = validate_mixed_precision_config(&config);
1097        assert!(result.is_err());
1098        let err_msg = format!("{}", result.expect_err("test"));
1099        assert!(err_msg.contains("BF16"));
1100    }
1101
1102    #[test]
1103    fn validate_bf16_on_ampere_ok() {
1104        let config = MixedPrecisionConfig::bf16_fp32(MixedSpMVAlgo::Vector, SmVersion::Sm80);
1105        assert!(validate_mixed_precision_config(&config).is_ok());
1106    }
1107
1108    // --- Plan tests ---
1109
1110    #[test]
1111    fn plan_auto_selects_scalar_for_sparse() {
1112        let config = default_fp16_config(MixedSpMVAlgo::Auto);
1113        // 1000 rows, 2000 nnz => avg 2 nnz/row => should select Scalar
1114        let plan = plan_mixed_precision_spmv(&config, 1000, 5000, 2000);
1115        assert!(plan.is_ok());
1116        let plan = plan.expect("test");
1117        assert_eq!(plan.config.algorithm, MixedSpMVAlgo::Scalar);
1118    }
1119
1120    #[test]
1121    fn plan_auto_selects_vector_for_moderate() {
1122        let config = default_fp16_config(MixedSpMVAlgo::Auto);
1123        // 1000 rows, 10000 nnz => avg 10 nnz/row => should select Vector
1124        let plan = plan_mixed_precision_spmv(&config, 1000, 5000, 10000);
1125        assert!(plan.is_ok());
1126        let plan = plan.expect("test");
1127        assert_eq!(plan.config.algorithm, MixedSpMVAlgo::Vector);
1128    }
1129
1130    #[test]
1131    fn plan_auto_selects_packed_for_dense() {
1132        let config = default_fp16_config(MixedSpMVAlgo::Auto);
1133        // 1000 rows, 50000 nnz => avg 50 nnz/row => should select VectorPacked
1134        let plan = plan_mixed_precision_spmv(&config, 1000, 5000, 50000);
1135        assert!(plan.is_ok());
1136        let plan = plan.expect("test");
1137        assert_eq!(plan.config.algorithm, MixedSpMVAlgo::VectorPacked);
1138    }
1139
1140    // --- Bandwidth estimation tests ---
1141
1142    #[test]
1143    fn bandwidth_savings_fp16_vs_fp32() {
1144        let config = default_fp16_config(MixedSpMVAlgo::Scalar);
1145        let plan = plan_mixed_precision_spmv(&config, 1000, 1000, 10000).expect("test");
1146        // FP16 = 2 bytes, FP32 = 4 bytes => ratio = 2.0
1147        let ratio = plan.bandwidth_savings_ratio();
1148        assert!((ratio - 2.0).abs() < 1e-10, "Expected ~2.0, got {ratio}");
1149    }
1150
1151    #[test]
1152    fn estimated_gflops_positive() {
1153        let config = default_fp16_config(MixedSpMVAlgo::Vector);
1154        let plan = plan_mixed_precision_spmv(&config, 10000, 10000, 100000).expect("test");
1155        // With 1000 GB/s peak bandwidth (A100-like)
1156        let gflops = plan.estimated_gflops(1000.0);
1157        assert!(gflops > 0.0, "Expected positive GFLOPS, got {gflops}");
1158        // Sanity: should be in a reasonable range for roofline
1159        assert!(gflops < 1000.0, "GFLOPS suspiciously high: {gflops}");
1160    }
1161
1162    // --- Precision loss estimation tests ---
1163
1164    #[test]
1165    fn precision_loss_fp16_bounded() {
1166        let loss = estimate_precision_loss(100.0, StoragePrecision::Fp16);
1167        // eps_fp16 = 2^-11 ~= 4.88e-4, eps_fp32 = 2^-24 ~= 5.96e-8
1168        // bound = 100 * (4.88e-4 + 5.96e-8) ~ 0.0488
1169        assert!(loss > 0.0);
1170        assert!(loss < 0.1, "Precision loss bound too large: {loss}");
1171    }
1172
1173    #[test]
1174    fn precision_loss_bf16_larger_than_fp16() {
1175        let loss_fp16 = estimate_precision_loss(50.0, StoragePrecision::Fp16);
1176        let loss_bf16 = estimate_precision_loss(50.0, StoragePrecision::Bf16);
1177        // BF16 has fewer mantissa bits, so larger error bound
1178        assert!(
1179            loss_bf16 > loss_fp16,
1180            "BF16 loss ({loss_bf16}) should exceed FP16 loss ({loss_fp16})"
1181        );
1182    }
1183
1184    // --- Launch parameter tests ---
1185
1186    #[test]
1187    fn scalar_launch_params_correct() {
1188        let (grid, block) = scalar_launch_params(1000);
1189        assert_eq!(block, 256);
1190        assert_eq!(grid, 4); // ceil(1000/256) = 4
1191    }
1192
1193    #[test]
1194    fn vector_launch_params_correct() {
1195        let (grid, block) = vector_launch_params(1000);
1196        assert_eq!(block, 256);
1197        // warps_per_block = 256/32 = 8, grid = ceil(1000/8) = 125
1198        assert_eq!(grid, 125);
1199    }
1200
1201    // --- Mixed-precision PTX instruction accuracy tests ---
1202
1203    /// Verify that scalar FP16→FP32 kernel contains `cvt.f32.f16` conversion.
1204    ///
1205    /// This ensures FP16 values are widened to FP32 before accumulation,
1206    /// which is the core requirement for mixed-precision SpMV numerical quality.
1207    #[test]
1208    fn mixed_precision_scalar_ptx_contains_fp16_to_fp32_conversion() {
1209        let config = MixedPrecisionConfig::fp16_fp32(MixedSpMVAlgo::Scalar, SmVersion::Sm80);
1210        let ptx = generate_mixed_scalar_spmv_ptx(&config);
1211        assert!(ptx.is_ok(), "PTX gen failed: {ptx:?}");
1212        let ptx = ptx.expect("test");
1213
1214        // Must contain FP16 → FP32 widening conversion
1215        assert!(
1216            ptx.contains("cvt.f32.f16"),
1217            "scalar kernel must contain cvt.f32.f16 for FP16→FP32 widening"
1218        );
1219    }
1220
1221    /// Verify that scalar FP16→FP32 kernel contains `fma.rn.f32` accumulation.
1222    ///
1223    /// The round-to-nearest FMA is required for correct FP32 accumulation quality.
1224    #[test]
1225    fn mixed_precision_scalar_ptx_contains_fma_rn_f32_accumulation() {
1226        let config = MixedPrecisionConfig::fp16_fp32(MixedSpMVAlgo::Scalar, SmVersion::Sm80);
1227        let ptx = generate_mixed_scalar_spmv_ptx(&config);
1228        assert!(ptx.is_ok(), "PTX gen failed: {ptx:?}");
1229        let ptx = ptx.expect("test");
1230
1231        // Must contain FP32 FMA with round-to-nearest mode for accuracy
1232        assert!(
1233            ptx.contains("fma.rn.f32"),
1234            "scalar kernel must contain fma.rn.f32 for FP32 accumulation"
1235        );
1236    }
1237
1238    /// Verify vector FP16→FP32 kernel contains both conversion and FMA instructions.
1239    #[test]
1240    fn mixed_precision_vector_ptx_contains_conversion_and_fma() {
1241        let config = MixedPrecisionConfig::fp16_fp32(MixedSpMVAlgo::Vector, SmVersion::Sm80);
1242        let ptx = generate_mixed_vector_spmv_ptx(&config);
1243        assert!(ptx.is_ok(), "PTX gen failed: {ptx:?}");
1244        let ptx = ptx.expect("test");
1245
1246        assert!(
1247            ptx.contains("cvt.f32.f16"),
1248            "vector kernel must contain cvt.f32.f16 for FP16→FP32 widening"
1249        );
1250        assert!(
1251            ptx.contains("fma.rn.f32"),
1252            "vector kernel must contain fma.rn.f32 for FP32 accumulation"
1253        );
1254    }
1255
1256    /// Verify BF16→FP32 kernel uses `cvt.f32.bf16` (not `cvt.f32.f16`).
1257    #[test]
1258    fn mixed_precision_bf16_ptx_uses_bf16_conversion() {
1259        let config = MixedPrecisionConfig::bf16_fp32(MixedSpMVAlgo::Scalar, SmVersion::Sm80);
1260        let ptx = generate_mixed_scalar_spmv_ptx(&config);
1261        assert!(ptx.is_ok(), "PTX gen failed: {ptx:?}");
1262        let ptx = ptx.expect("test");
1263
1264        assert!(
1265            ptx.contains("cvt.f32.bf16"),
1266            "BF16 kernel must contain cvt.f32.bf16 for BF16→FP32 widening"
1267        );
1268        // BF16 kernel should NOT use the FP16 conversion instruction
1269        assert!(
1270            !ptx.contains("cvt.f32.f16"),
1271            "BF16 kernel must NOT contain cvt.f32.f16"
1272        );
1273    }
1274
1275    /// Verify that `mul.rn.f32` is used for alpha/beta scaling (not just fma).
1276    #[test]
1277    fn mixed_precision_scalar_ptx_uses_rn_mode_for_scaling() {
1278        let config = MixedPrecisionConfig::fp16_fp32(MixedSpMVAlgo::Scalar, SmVersion::Sm80);
1279        let ptx = generate_mixed_scalar_spmv_ptx(&config);
1280        assert!(ptx.is_ok(), "PTX gen failed: {ptx:?}");
1281        let ptx = ptx.expect("test");
1282
1283        // The alpha/beta scaling uses mul.rn.f32
1284        assert!(
1285            ptx.contains("mul.rn.f32"),
1286            "scalar kernel must contain mul.rn.f32 for alpha/beta scaling"
1287        );
1288    }
1289
1290    /// Verify precision loss estimation is monotone: more nnz → larger error bound.
1291    #[test]
1292    fn precision_loss_monotone_in_nnz_per_row() {
1293        let nnz_values = [1.0_f64, 10.0, 100.0, 1000.0];
1294        let mut prev_loss = 0.0_f64;
1295        for &nnz in &nnz_values {
1296            let loss = estimate_precision_loss(nnz, StoragePrecision::Fp16);
1297            assert!(
1298                loss >= prev_loss,
1299                "precision loss should increase with nnz/row: nnz={nnz}, loss={loss}, prev={prev_loss}"
1300            );
1301            prev_loss = loss;
1302        }
1303    }
1304
1305    /// Verify that the precision loss is proportional to nnz/row.
1306    ///
1307    /// From the formula: loss = nnz * (eps_storage + eps_compute),
1308    /// so doubling nnz should double the loss.
1309    #[test]
1310    fn precision_loss_linear_in_nnz_per_row() {
1311        let loss_10 = estimate_precision_loss(10.0, StoragePrecision::Fp16);
1312        let loss_20 = estimate_precision_loss(20.0, StoragePrecision::Fp16);
1313
1314        // loss_20 should be exactly 2 * loss_10 (linear in nnz)
1315        assert!(
1316            (loss_20 - 2.0 * loss_10).abs() < 1e-15,
1317            "Precision loss should be linear: loss(20)={loss_20} should be 2*loss(10)={loss_10}"
1318        );
1319    }
1320
1321    /// Verify that the bandwidth savings ratio for BF16 is also ~2x vs FP32.
1322    #[test]
1323    fn bandwidth_savings_bf16_vs_fp32() {
1324        let config = default_bf16_config(MixedSpMVAlgo::Scalar);
1325        let plan = plan_mixed_precision_spmv(&config, 1000, 1000, 10000).expect("test");
1326        // BF16 = 2 bytes, FP32 = 4 bytes => ratio = 2.0
1327        let ratio = plan.bandwidth_savings_ratio();
1328        assert!(
1329            (ratio - 2.0).abs() < 1e-10,
1330            "BF16 bandwidth savings ratio should be ~2.0, got {ratio}"
1331        );
1332    }
1333
1334    /// Verify the avg_nnz_per_row calculation is correct.
1335    #[test]
1336    fn mixed_plan_avg_nnz_per_row_calculation() {
1337        let config = default_fp16_config(MixedSpMVAlgo::Scalar);
1338        // 100 rows, 500 nnz => avg = 5.0
1339        let plan = plan_mixed_precision_spmv(&config, 100, 1000, 500).expect("test");
1340        let avg = plan.avg_nnz_per_row();
1341        assert!(
1342            (avg - 5.0).abs() < 1e-10,
1343            "avg_nnz_per_row should be 5.0, got {avg}"
1344        );
1345    }
1346}