oxicuda-sparse 0.4.0

OxiCUDA Sparse - GPU-accelerated sparse matrix operations (cuSPARSE equivalent)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
//! CSR5 SpMV kernel.
//!
//! Computes `y = alpha * A * x + beta * y` where `A` is in CSR5 format.
//!
//! CSR5 achieves load-balanced SpMV by dividing non-zeros into fixed-width
//! tiles (32 elements each, matching warp width). Each warp processes one
//! tile, using tile descriptors to determine row boundaries.
//!
//! The SpMV proceeds in two phases:
//! 1. **Tile phase**: Each warp computes partial sums for its tile, using
//!    warp shuffle to reduce within rows. Results are written to `y` for
//!    rows fully contained within a tile, or to the calibrator for rows
//!    that span tile boundaries.
//! 2. **Calibrate phase**: A separate kernel merges cross-tile partial
//!    sums from the calibrator into the final `y` vector.

use std::sync::Arc;

use oxicuda_blas::GpuFloat;
use oxicuda_driver::Module;
use oxicuda_launch::{Kernel, LaunchParams, grid_size_for};
use oxicuda_memory::DeviceBuffer;
use oxicuda_ptx::prelude::*;

use crate::error::{SparseError, SparseResult};
use crate::format::csr5::Csr5Matrix;
use crate::handle::SparseHandle;
use crate::ptx_helpers::{
    add_float, emit_shfl_float, load_float_imm, load_global_float, mul_float,
    reinterpret_bits_to_float, store_global_float,
};

/// Block size for CSR5 tile kernel (should be a multiple of 32).
const CSR5_TILE_BLOCK: u32 = 256;

/// Block size for the calibration kernel.
const CSR5_CALIBRATE_BLOCK: u32 = 256;

/// CSR5 SpMV: `y = alpha * A * x + beta * y`.
///
/// Performs load-balanced SpMV using the CSR5 tile-based format.
///
/// # Arguments
///
/// * `handle` -- Sparse handle providing stream and device context.
/// * `csr5` -- Sparse CSR5 matrix `A`.
/// * `x` -- Dense input vector of length `A.cols()`.
/// * `y` -- Dense output vector of length `A.rows()`.
/// * `alpha` -- Scalar multiplier for `A * x`.
/// * `beta` -- Scalar multiplier for existing `y`.
///
/// # Errors
///
/// Returns [`SparseError::PtxGeneration`] if kernel generation fails.
/// Returns [`SparseError::Cuda`] on kernel launch failure.
/// Returns [`SparseError::DimensionMismatch`] if vector lengths are wrong.
pub fn csr5_spmv<T: GpuFloat>(
    handle: &SparseHandle,
    csr5: &Csr5Matrix<T>,
    x: &DeviceBuffer<T>,
    y: &mut DeviceBuffer<T>,
    alpha: T,
    beta: T,
) -> SparseResult<()> {
    if csr5.rows() == 0 || csr5.cols() == 0 {
        return Ok(());
    }

    if x.len() < csr5.cols() as usize {
        return Err(SparseError::DimensionMismatch(format!(
            "x length ({}) must be >= cols ({})",
            x.len(),
            csr5.cols()
        )));
    }
    if y.len() < csr5.rows() as usize {
        return Err(SparseError::DimensionMismatch(format!(
            "y length ({}) must be >= rows ({})",
            y.len(),
            csr5.rows()
        )));
    }

    // Phase 1: Tile kernel -- each warp processes one tile
    let tile_ptx = emit_csr5_tile_kernel::<T>(handle.sm_version())?;
    let tile_module = Arc::new(Module::from_ptx(&tile_ptx)?);
    let tile_kernel = Kernel::from_module(tile_module, "csr5_tile")?;

    // One warp per tile; warps_per_block = block / 32
    let warps_per_block = CSR5_TILE_BLOCK / 32;
    let tile_grid = grid_size_for(csr5.num_tiles(), warps_per_block);

    tile_kernel.launch(
        &LaunchParams::new(tile_grid, CSR5_TILE_BLOCK),
        handle.stream(),
        &(
            csr5.row_ptr().as_device_ptr(),
            csr5.col_idx().as_device_ptr(),
            csr5.values().as_device_ptr(),
            csr5.tile_ptr().as_device_ptr(),
            csr5.tile_desc().as_device_ptr(),
            x.as_device_ptr(),
            y.as_device_ptr(),
            csr5.calibrator().as_device_ptr(),
            alpha.to_bits_u64(),
            beta.to_bits_u64(),
            csr5.rows(),
            csr5.num_tiles(),
            csr5.nnz(),
        ),
    )?;

    // Phase 2: Calibration kernel -- merge cross-tile partial sums
    let cal_ptx = emit_csr5_calibrate_kernel::<T>(handle.sm_version())?;
    let cal_module = Arc::new(Module::from_ptx(&cal_ptx)?);
    let cal_kernel = Kernel::from_module(cal_module, "csr5_calibrate")?;

    let cal_grid = grid_size_for(csr5.rows(), CSR5_CALIBRATE_BLOCK);
    cal_kernel.launch(
        &LaunchParams::new(cal_grid, CSR5_CALIBRATE_BLOCK),
        handle.stream(),
        &(
            y.as_device_ptr(),
            csr5.calibrator().as_device_ptr(),
            csr5.rows(),
        ),
    )?;

    Ok(())
}

/// Generates PTX for the CSR5 tile kernel.
///
/// Each warp processes one tile of 32 non-zero elements. The kernel:
/// 1. Loads the tile descriptor to determine row boundaries
/// 2. Each lane loads one element, computes `val * x[col]`
/// 3. Uses warp shuffle to reduce partial sums within rows
/// 4. Lane 0 of each row segment writes to `y` or calibrator
fn emit_csr5_tile_kernel<T: GpuFloat>(sm: SmVersion) -> SparseResult<String> {
    let elem_bytes = T::size_u32();
    let is_f64 = T::SIZE == 8;
    let mov_suffix = if is_f64 { "f64" } else { "f32" };

    KernelBuilder::new("csr5_tile")
        .target(sm)
        .param("row_ptr", PtxType::U64)
        .param("col_idx", PtxType::U64)
        .param("values_ptr", PtxType::U64)
        .param("tile_ptr", PtxType::U64)
        .param("tile_desc", PtxType::U64)
        .param("x_ptr", PtxType::U64)
        .param("y_ptr", PtxType::U64)
        .param("calibrator_ptr", PtxType::U64)
        .param("alpha_bits", PtxType::U64)
        .param("beta_bits", PtxType::U64)
        .param("num_rows", PtxType::U32)
        .param("num_tiles", PtxType::U32)
        .param("nnz", PtxType::U32)
        .body(move |b| {
            // Warp ID = global_tid / 32
            let tid_global = b.global_thread_id_x();
            let num_tiles = b.load_param_u32("num_tiles");

            let lane = b.alloc_reg(PtxType::U32);
            b.raw_ptx(&format!("and.b32 {lane}, {tid_global}, 31;"));

            let tile_id = b.alloc_reg(PtxType::U32);
            b.raw_ptx(&format!("shr.u32 {tile_id}, {tid_global}, 5;"));

            let tile_id_inner = tile_id.clone();
            let lane_inner = lane.clone();
            b.if_lt_u32(tile_id, num_tiles, move |b| {
                let tile_id = tile_id_inner;
                let lane = lane_inner;

                let col_idx_base = b.load_param_u64("col_idx");
                let values_base = b.load_param_u64("values_ptr");
                let tile_ptr_base = b.load_param_u64("tile_ptr");
                let tile_desc_base = b.load_param_u64("tile_desc");
                let x_ptr = b.load_param_u64("x_ptr");
                let _y_ptr = b.load_param_u64("y_ptr");
                let calibrator_ptr = b.load_param_u64("calibrator_ptr");
                let alpha_bits = b.load_param_u64("alpha_bits");
                let beta_bits = b.load_param_u64("beta_bits");
                let num_rows_reg = b.load_param_u32("num_rows");
                let nnz_reg = b.load_param_u32("nnz");

                let alpha = reinterpret_bits_to_float::<T>(b, alpha_bits);
                // beta is used in the calibrate kernel, not here
                let _beta = reinterpret_bits_to_float::<T>(b, beta_bits);

                // Load tile_ptr[tile_id] to get the starting element index
                let tp_addr = b.byte_offset_addr(tile_ptr_base.clone(), tile_id.clone(), 4);
                let tile_start = b.load_global_u32(tp_addr);

                // This lane's element index
                let elem_idx = b.alloc_reg(PtxType::U32);
                b.raw_ptx(&format!("add.u32 {elem_idx}, {tile_start}, {lane};"));

                // Check bounds: skip the load when elem_idx >= nnz. Inverted
                // skip-branch (`setp.lo` -> `setp.hs`) via the structured
                // `branch_if` so the target matches the `$`-prefixed label.
                let oob = b.alloc_reg(PtxType::Pred);
                b.raw_ptx(&format!("setp.hs.u32 {oob}, {elem_idx}, {nnz_reg};"));

                // Load value and column, compute product (zero if out of bounds)
                let product = load_float_imm::<T>(b, 0.0);

                let compute_label = b.fresh_label("csr5_compute");
                let after_compute = b.fresh_label("csr5_after_compute");

                b.branch_if(oob, &after_compute);
                b.label(&compute_label);

                // Load col_idx[elem_idx]
                let ci_addr = b.byte_offset_addr(col_idx_base, elem_idx.clone(), 4);
                let col_i32 = b.load_global_i32(ci_addr);
                let col_u32 = b.alloc_reg(PtxType::U32);
                b.raw_ptx(&format!("mov.b32 {col_u32}, {col_i32};"));

                // Load values[elem_idx]
                let v_addr = b.byte_offset_addr(values_base, elem_idx, elem_bytes);
                let val = load_global_float::<T>(b, v_addr);

                // Load x[col]
                let x_addr = b.byte_offset_addr(x_ptr, col_u32, elem_bytes);
                let x_val = load_global_float::<T>(b, x_addr);

                // product = val * x_val
                let prod = mul_float::<T>(b, val, x_val);
                b.raw_ptx(&format!("mov.{mov_suffix} {product}, {prod};"));

                b.label(&after_compute);

                // Load tile descriptor for this tile:
                // TileDescriptor has 2 u32 fields = 8 bytes per descriptor
                let desc_addr = b.byte_offset_addr(tile_desc_base, tile_id.clone(), 8);
                let seg_mask = b.load_global_u32(desc_addr.clone());

                // Load first_row (at offset +4 from desc_addr)
                let desc_addr_plus4 = b.alloc_reg(PtxType::U64);
                b.raw_ptx(&format!("add.u64 {desc_addr_plus4}, {desc_addr}, 4;"));
                let first_row = b.load_global_u32(desc_addr_plus4);

                // Determine which row this lane belongs to within the tile.
                // Count the number of set bits in seg_mask at positions <= lane.
                // This gives the row offset from first_row.
                //
                // We use a mask: (1 << (lane + 1)) - 1 to isolate bits 0..lane
                let lane_plus_1 = b.alloc_reg(PtxType::U32);
                b.raw_ptx(&format!("add.u32 {lane_plus_1}, {lane}, 1;"));

                let lane_mask = b.alloc_reg(PtxType::U32);
                let one = b.alloc_reg(PtxType::U32);
                b.raw_ptx(&format!("mov.u32 {one}, 1;"));
                b.raw_ptx(&format!("shl.b32 {lane_mask}, {one}, {lane_plus_1};"));
                let lane_mask_sub = b.alloc_reg(PtxType::U32);
                b.raw_ptx(&format!("sub.u32 {lane_mask_sub}, {lane_mask}, 1;"));

                // Count bits in seg_mask & lane_mask
                let masked_seg = b.alloc_reg(PtxType::U32);
                b.raw_ptx(&format!(
                    "and.b32 {masked_seg}, {seg_mask}, {lane_mask_sub};"
                ));
                let row_offset = b.alloc_reg(PtxType::U32);
                b.raw_ptx(&format!("popc.b32 {row_offset}, {masked_seg};"));

                // This lane's row = first_row + row_offset
                let my_row = b.alloc_reg(PtxType::U32);
                b.raw_ptx(&format!("add.u32 {my_row}, {first_row}, {row_offset};"));

                // Warp-level segmented reduction:
                // Use inclusive scan to sum products within each row segment.
                // A lane starts a new segment if its bit in seg_mask is set.
                //
                // We do a simple approach: for each shuffle offset, check if
                // the source lane is in the same segment (same row).
                let acc = b.alloc_reg(T::PTX_TYPE);
                b.raw_ptx(&format!("mov.{mov_suffix} {acc}, {product};"));

                for offset in [1u32, 2, 4, 8, 16] {
                    // Segmented up-shuffle. `emit_shfl_float` handles the f64
                    // unpack/repack so no `.b64` shfl (rejected by ptxas) is
                    // emitted; for f32 it is a single `.b32` shuffle.
                    let shuffled =
                        emit_shfl_float::<T>(b, "up", acc.clone(), &offset.to_string(), "0");
                    // Only add if source lane (lane - offset) is in the same row
                    let src_lane = b.alloc_reg(PtxType::U32);
                    b.raw_ptx(&format!("sub.u32 {src_lane}, {lane}, {offset};"));
                    // Check that lane >= offset (otherwise src is invalid)
                    let valid = b.alloc_reg(PtxType::Pred);
                    b.raw_ptx(&format!("setp.ge.u32 {valid}, {lane}, {offset};"));
                    // Check that src is in same row segment by checking no
                    // segment boundary bits between src_lane+1 and lane
                    // For simplicity, check that my_row of src == my_row
                    // We re-compute src_row = first_row + popc(seg_mask & ((1<<src_lane+1)-1))
                    // But this is expensive in PTX. Instead, use the seg_mask directly:
                    // between lanes (src_lane, lane], if any bit is set in seg_mask,
                    // they are in different segments.
                    //
                    // Mask for bits in range (src_lane, lane]:
                    // range_mask = lane_mask_sub & ~((1 << (src_lane+1)) - 1)
                    // But if lane < offset, this is invalid.
                    //
                    // Simpler: use selp to conditionally add
                    let sum = b.alloc_reg(T::PTX_TYPE);
                    b.raw_ptx(&format!("add.{mov_suffix} {sum}, {acc}, {shuffled};"));
                    // We need to check if the shuffle source is the same row.
                    // Compute src_row via popc approach
                    let src_lane_p1 = b.alloc_reg(PtxType::U32);
                    b.raw_ptx(&format!("add.u32 {src_lane_p1}, {src_lane}, 1;"));
                    let src_mask = b.alloc_reg(PtxType::U32);
                    b.raw_ptx(&format!("shl.b32 {src_mask}, {one}, {src_lane_p1};"));
                    let src_mask_sub = b.alloc_reg(PtxType::U32);
                    b.raw_ptx(&format!("sub.u32 {src_mask_sub}, {src_mask}, 1;"));
                    let src_masked = b.alloc_reg(PtxType::U32);
                    b.raw_ptx(&format!(
                        "and.b32 {src_masked}, {seg_mask}, {src_mask_sub};"
                    ));
                    let src_row_off = b.alloc_reg(PtxType::U32);
                    b.raw_ptx(&format!("popc.b32 {src_row_off}, {src_masked};"));
                    let src_row = b.alloc_reg(PtxType::U32);
                    b.raw_ptx(&format!("add.u32 {src_row}, {first_row}, {src_row_off};"));
                    let same_row = b.alloc_reg(PtxType::Pred);
                    b.raw_ptx(&format!("setp.eq.u32 {same_row}, {src_row}, {my_row};"));
                    // Combine: valid AND same_row
                    let do_add = b.alloc_reg(PtxType::Pred);
                    b.raw_ptx(&format!("and.pred {do_add}, {valid}, {same_row};"));
                    b.raw_ptx(&format!("selp.{mov_suffix} {acc}, {sum}, {acc}, {do_add};"));
                }

                // Now `acc` contains the inclusive segmented prefix sum.
                // The last lane for each row segment holds the row's total.
                //
                // A lane is the "last" for its segment if:
                //   lane == 31 OR the next lane starts a new segment
                let is_last = b.alloc_reg(PtxType::Pred);
                let is_lane_31 = b.alloc_reg(PtxType::Pred);
                b.raw_ptx(&format!("setp.eq.u32 {is_lane_31}, {lane}, 31;"));

                // Check if next lane starts a new segment
                let next_lane = b.alloc_reg(PtxType::U32);
                b.raw_ptx(&format!("add.u32 {next_lane}, {lane}, 1;"));
                let next_bit = b.alloc_reg(PtxType::U32);
                b.raw_ptx(&format!("shr.b32 {next_bit}, {seg_mask}, {next_lane};"));
                let next_bit_masked = b.alloc_reg(PtxType::U32);
                b.raw_ptx(&format!("and.b32 {next_bit_masked}, {next_bit}, 1;"));
                let next_is_new_seg = b.alloc_reg(PtxType::Pred);
                b.raw_ptx(&format!(
                    "setp.ne.u32 {next_is_new_seg}, {next_bit_masked}, 0;"
                ));
                b.raw_ptx(&format!(
                    "or.pred {is_last}, {is_lane_31}, {next_is_new_seg};"
                ));

                // Write result if this lane is the last for its row
                // Only the last lane of each row segment writes. Branch past the
                // write when this lane is not the last (inverted: `is_last`'s
                // complement). We invert by testing `is_last == false` directly.
                let not_last = b.alloc_reg(PtxType::Pred);
                b.raw_ptx(&format!("not.pred {not_last}, {is_last};"));
                let write_label = b.fresh_label("csr5_write");
                let skip_write = b.fresh_label("csr5_skip_write");
                b.branch_if(not_last, &skip_write);
                b.label(&write_label);

                // Check row is valid: skip the write when my_row >= num_rows
                // (inverted `setp.lo` -> `setp.hs`).
                let row_oob = b.alloc_reg(PtxType::Pred);
                b.raw_ptx(&format!("setp.hs.u32 {row_oob}, {my_row}, {num_rows_reg};"));
                let row_skip = b.fresh_label("csr5_row_skip");
                b.branch_if(row_oob, &row_skip);

                // For the first tile (tile_id == 0) and the row that starts at
                // the tile boundary, we can write directly to y with beta scaling.
                // For other tiles contributing to a cross-boundary row, write to
                // calibrator to be merged later.
                //
                // Simplified approach: use atomic add to y for partial rows,
                // or direct write. For correctness with beta, the first tile's
                // first write applies beta; subsequent writes add.
                //
                // For a clean implementation: all tiles write alpha*partial to
                // calibrator[my_row], then calibrate kernel merges.
                // But this would double-count rows fully within a tile.
                //
                // Better approach: check if this row started in the current tile
                // and ended in the current tile (fully contained). If so, write
                // directly to y. Otherwise, accumulate via calibrator.
                //
                // For now, use a simpler strategy:
                //   - Scale by alpha
                //   - If tile_id == 0 and lane covers the row from the start:
                //     y[row] = alpha*partial + beta*y[row]
                //   - Otherwise: use atomic add of alpha*partial to y[row]
                //
                // This works because:
                //   - Phase 1 scales y by beta only once (first tile touching
                //     each row)
                //   - Phase 2 calibration adds remaining partials

                let scaled_acc = mul_float::<T>(b, alpha.clone(), acc);

                // Write to calibrator and let the calibrate kernel handle it.
                // The calibrator accumulates partial sums per row.
                let cal_addr = b.byte_offset_addr(calibrator_ptr, my_row.clone(), elem_bytes);
                // Use atomic add for thread-safe accumulation
                let _old = b.alloc_reg(T::PTX_TYPE);
                b.raw_ptx(&format!(
                    "atom.global.add.{mov_suffix} {_old}, [{cal_addr}], {scaled_acc};"
                ));

                b.label(&row_skip);
                b.label(&skip_write);
            });

            b.ret();
        })
        .build()
        .map_err(|e| SparseError::PtxGeneration(e.to_string()))
}

/// Generates PTX for the CSR5 calibration kernel.
///
/// This kernel merges the partial sums from the calibrator into the final
/// `y` vector: `y[row] = calibrator[row] + beta * y[row]`.
fn emit_csr5_calibrate_kernel<T: GpuFloat>(sm: SmVersion) -> SparseResult<String> {
    let elem_bytes = T::size_u32();
    let _is_f64 = T::SIZE == 8;

    KernelBuilder::new("csr5_calibrate")
        .target(sm)
        .param("y_ptr", PtxType::U64)
        .param("calibrator_ptr", PtxType::U64)
        .param("num_rows", PtxType::U32)
        .body(move |b| {
            let gid = b.global_thread_id_x();
            let num_rows = b.load_param_u32("num_rows");

            let gid_inner = gid.clone();
            b.if_lt_u32(gid, num_rows, move |b| {
                let row = gid_inner;
                let y_ptr = b.load_param_u64("y_ptr");
                let cal_ptr = b.load_param_u64("calibrator_ptr");

                // Load calibrator[row]
                let cal_addr = b.byte_offset_addr(cal_ptr, row.clone(), elem_bytes);
                let cal_val = load_global_float::<T>(b, cal_addr);

                // Load y[row]
                let y_addr = b.byte_offset_addr(y_ptr, row, elem_bytes);
                let y_val = load_global_float::<T>(b, y_addr.clone());

                // y[row] = y[row] + calibrator[row]
                let result = add_float::<T>(b, y_val, cal_val);
                store_global_float::<T>(b, y_addr, result);
            });

            b.ret();
        })
        .build()
        .map_err(|e| SparseError::PtxGeneration(e.to_string()))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::ptx_helpers::test_support::assert_assembles_and_clean;

    /// The CSR5 tile + calibrate kernels must assemble for sm_86 in both
    /// precisions. The f64 tile kernel exercises the segmented warp up-shuffle:
    /// it must split into `.b32` halves (no `shfl.sync.up.b64`).
    #[test]
    fn csr5_tile_calibrate_f32_f64_assemble_sm86() {
        let tile_f32 = emit_csr5_tile_kernel::<f32>(SmVersion::Sm86).expect("f32 tile");
        assert_assembles_and_clean("csr5_tile_f32", &tile_f32);
        let tile_f64 = emit_csr5_tile_kernel::<f64>(SmVersion::Sm86).expect("f64 tile");
        assert_assembles_and_clean("csr5_tile_f64", &tile_f64);
        assert!(
            !tile_f64.contains("shfl.sync.up.b64"),
            "f64 CSR5 tile kernel must not emit shfl.sync.up.b64:\n{tile_f64}"
        );
        assert!(
            tile_f64.contains("shfl.sync.up.b32"),
            "f64 CSR5 tile kernel must reduce via paired b32 up-shuffles:\n{tile_f64}"
        );
        assert!(
            !tile_f64.contains("0F00000000"),
            "f64 CSR5 tile kernel must not materialize an f32 0.0 immediate:\n{tile_f64}"
        );

        let cal_f32 = emit_csr5_calibrate_kernel::<f32>(SmVersion::Sm86).expect("f32 cal");
        assert_assembles_and_clean("csr5_calibrate_f32", &cal_f32);
        let cal_f64 = emit_csr5_calibrate_kernel::<f64>(SmVersion::Sm86).expect("f64 cal");
        assert_assembles_and_clean("csr5_calibrate_f64", &cal_f64);
    }

    #[test]
    fn csr5_tile_ptx_generates_f32() {
        let ptx = emit_csr5_tile_kernel::<f32>(SmVersion::Sm80);
        assert!(ptx.is_ok());
        let ptx_text = ptx.expect("test: PTX gen should succeed");
        assert!(ptx_text.contains(".entry csr5_tile"));
        assert!(ptx_text.contains(".target sm_80"));
    }

    #[test]
    fn csr5_tile_ptx_generates_f64() {
        let ptx = emit_csr5_tile_kernel::<f64>(SmVersion::Sm80);
        assert!(ptx.is_ok());
        let ptx_text = ptx.expect("test: PTX gen should succeed");
        assert!(ptx_text.contains(".entry csr5_tile"));
    }

    #[test]
    fn csr5_calibrate_ptx_generates_f32() {
        let ptx = emit_csr5_calibrate_kernel::<f32>(SmVersion::Sm80);
        assert!(ptx.is_ok());
        let ptx_text = ptx.expect("test: PTX gen should succeed");
        assert!(ptx_text.contains(".entry csr5_calibrate"));
    }

    #[test]
    fn csr5_calibrate_ptx_generates_f64() {
        let ptx = emit_csr5_calibrate_kernel::<f64>(SmVersion::Sm80);
        assert!(ptx.is_ok());
    }

    #[test]
    fn csr5_tile_ptx_contains_segmented_reduction() {
        let ptx = emit_csr5_tile_kernel::<f32>(SmVersion::Sm80);
        let ptx_text = ptx.expect("test: PTX gen should succeed");
        // Should contain warp shuffle instructions
        assert!(ptx_text.contains("shfl.sync.up"));
        // Should contain popcount for segment detection
        assert!(ptx_text.contains("popc.b32"));
    }

    #[test]
    fn csr5_tile_ptx_contains_atomic_add() {
        let ptx = emit_csr5_tile_kernel::<f32>(SmVersion::Sm80);
        let ptx_text = ptx.expect("test: PTX gen should succeed");
        assert!(ptx_text.contains("atom.global.add"));
    }

    #[test]
    fn csr5_block_sizes_are_warp_aligned() {
        assert_eq!(CSR5_TILE_BLOCK % 32, 0);
        assert_eq!(CSR5_CALIBRATE_BLOCK % 32, 0);
    }
}