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