spark_runtime/cuda_backend/tensormap.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! TMA descriptors (`CUtensorMap`) for `cp.async.bulk.tensor` loads.
4//!
5//! A TMA descriptor moves address generation, tiling and BOUNDS CHECKING for a
6//! global→shared copy into hardware: the kernel supplies tile coordinates and
7//! the copy engine does the rest, with out-of-range elements zero-filled instead
8//! of masked by hand. That removes the per-row index arithmetic, the tail
9//! predication and the register round-trip that `cp.async` still pays.
10//!
11//! ## Why this exists
12//!
13//! `kernels/gb10/common/gated_delta_rule_fla.cu` claimed "GB10 sm_121 has
14//! cp.async.cg (NO TMA)". That is false: `cp.async.bulk.tensor` + `mbarrier`
15//! compile for `sm_121a` under CUDA 13.0, and the FlashQLA GDN spine measured on
16//! a GB10 at 15.0 ms uses exactly that against our 105.7 ms. Nothing in
17//! `kernels/` used TMA before this module.
18//!
19//! ## Contract (the parts that bite)
20//!
21//! * The descriptor is **128 bytes aligned to 64** — hence `repr(C, align(64))`.
22//! It is passed BY VALUE to a `__grid_constant__ const CUtensorMap` parameter,
23//! which is why `KernelLaunch::arg_tensormap` has to occupy 16 slots.
24//! * `global_strides` carries `rank - 1` entries **in bytes**. The innermost
25//! stride is implicit and must equal the element size, so a tensor whose fast
26//! axis is not contiguous cannot be described.
27//! * Every stride must be 16-byte aligned, and so must `global_address`.
28//! * Dimensions are in ELEMENTS and are ordered fastest-varying FIRST — the
29//! opposite of the row-major `[rows][cols]` we write everywhere else. Getting
30//! this backwards does not error; it silently transposes the load.
31
32use anyhow::{Result, bail};
33
34use crate::gpu::DevicePtr;
35
36/// Verified against `/usr/local/cuda/include/cuda.h` (CUDA 13.0). Note
37/// `BFLOAT16 = 9`: `FLOAT64` sits at 8, ahead of it, and `FLOAT32_FTZ` is 10 —
38/// the order most references get wrong. An incorrect dtype here does not fail,
39/// it reinterprets the bytes.
40const CU_TENSOR_MAP_DATA_TYPE_BFLOAT16: u32 = 9;
41const CU_TENSOR_MAP_INTERLEAVE_NONE: u32 = 0;
42const CU_TENSOR_MAP_SWIZZLE_NONE: u32 = 0;
43const CU_TENSOR_MAP_L2_PROMOTION_L2_128B: u32 = 2;
44const CU_TENSOR_MAP_FLOAT_OOB_FILL_NONE: u32 = 0;
45
46/// `cuTensorMapEncodeTiled`, looked up once in the already-loaded libcuda.
47///
48/// `RTLD_DEFAULT` searches the global scope, so this finds the driver the
49/// process has already loaded rather than opening a second copy. Cached in a
50/// `OnceLock`: the lookup is cheap but not free, and descriptor construction sits
51/// on the prefill path.
52type EncodeTiledFn = unsafe extern "C" fn(
53 *mut u8,
54 u32,
55 u32,
56 u64,
57 *const u64,
58 *const u64,
59 *const u32,
60 *const u32,
61 u32,
62 u32,
63 u32,
64 u32,
65) -> i32;
66
67fn tensor_map_encode_tiled() -> Option<EncodeTiledFn> {
68 static CACHED: std::sync::OnceLock<Option<usize>> = std::sync::OnceLock::new();
69 let addr = (*CACHED.get_or_init(|| {
70 // `dlsym` exists only on unix — on Windows this symbol has no libc
71 // provider and the build DIED AT LINK (LNK2019 unresolved external
72 // `dlsym`, seen on the windows-x86_64 release-matrix builds
73 // 2026-08-22). TMA is opt-in (`ATLAS_GDN_TMA=1`), measured neutral,
74 // and unresolved here just routes callers to their non-TMA path — so
75 // Windows reporting "unavailable" is the honest and cheap behaviour,
76 // not a loss of function.
77 #[cfg(unix)]
78 {
79 unsafe extern "C" {
80 fn dlsym(handle: *mut std::ffi::c_void, symbol: *const i8)
81 -> *mut std::ffi::c_void;
82 }
83 let name = c"cuTensorMapEncodeTiled";
84 let p = unsafe { dlsym(std::ptr::null_mut(), name.as_ptr().cast::<i8>()) };
85 if p.is_null() { None } else { Some(p as usize) }
86 }
87 #[cfg(not(unix))]
88 {
89 None
90 }
91 }))?;
92 // SAFETY: the symbol resolved from libcuda has exactly this signature; it is
93 // the documented prototype for cuTensorMapEncodeTiled.
94 Some(unsafe { std::mem::transmute::<usize, EncodeTiledFn>(addr) })
95}
96
97/// A 128-byte TMA descriptor, aligned as the driver requires.
98#[repr(C, align(64))]
99#[derive(Clone, Copy)]
100pub struct TensorMap([u8; 128]);
101
102impl std::fmt::Debug for TensorMap {
103 /// The 128 bytes are an opaque driver-owned blob; printing them is noise.
104 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105 f.write_str("TensorMap(<128 bytes>)")
106 }
107}
108
109impl TensorMap {
110 /// The raw bytes, for `KernelLaunch::arg_tensormap`.
111 pub fn bytes(&self) -> &[u8; 128] {
112 &self.0
113 }
114
115 /// Describe a row-major bf16 matrix as tiles of `box_rows x box_cols`.
116 ///
117 /// `rows`/`cols` and `row_stride_elems` are in ELEMENTS, in the row-major
118 /// terms the rest of the codebase uses; the fastest-varying-first ordering
119 /// the driver wants is applied here so callers never have to think about it.
120 /// `row_stride_elems` is the distance between consecutive rows and may
121 /// exceed `cols` (a view into a wider tensor).
122 ///
123 /// Loads of tiles that hang off the end are ZERO-FILLED by hardware, so the
124 /// caller does not predicate the tail.
125 pub fn tiled_2d_bf16(
126 global: DevicePtr,
127 rows: u64,
128 cols: u64,
129 row_stride_elems: u64,
130 box_rows: u32,
131 box_cols: u32,
132 ) -> Result<Self> {
133 // Fail fast on the alignment rules rather than let the driver return a
134 // generic invalid-argument, or worse, succeed and mis-address.
135 if !global.0.is_multiple_of(16) {
136 bail!("TMA global address {:#x} is not 16-byte aligned", global.0);
137 }
138 let row_stride_bytes = row_stride_elems * 2;
139 if !row_stride_bytes.is_multiple_of(16) {
140 bail!(
141 "TMA row stride {row_stride_elems} elems ({row_stride_bytes} B) is not \
142 16-byte aligned; bf16 needs a stride that is a multiple of 8 elements"
143 );
144 }
145 if row_stride_elems < cols {
146 bail!("TMA row stride {row_stride_elems} is narrower than cols {cols}");
147 }
148 if box_rows == 0 || box_cols == 0 {
149 bail!("TMA box dims must be non-zero, got {box_rows}x{box_cols}");
150 }
151
152 // Fastest-varying axis FIRST: for row-major [rows][cols] that is cols.
153 let global_dim: [u64; 2] = [cols, rows];
154 // rank-1 strides, in bytes, skipping the implicit innermost one.
155 let global_strides: [u64; 1] = [row_stride_bytes];
156 let box_dim: [u32; 2] = [box_cols, box_rows];
157 let element_strides: [u32; 2] = [1, 1];
158
159 // ★ RESOLVED AT RUNTIME, NOT LINKED. `cuTensorMapEncodeTiled` is a CUDA
160 // 12.0+ driver entry point, and a link-time `extern "C"` makes the whole
161 // workspace fail to LINK anywhere the available libcuda stub predates it:
162 //
163 // rust-lld: error: undefined symbol: cuTensorMapEncodeTiled
164 //
165 // which is what CI hit while a local build with CUDA 13.0 linked fine.
166 // `dlsym` also degrades honestly — a driver without the symbol yields a
167 // clear error here and the caller falls back to its non-TMA path, rather
168 // than the binary refusing to build for everyone.
169 let f = tensor_map_encode_tiled().ok_or_else(|| {
170 anyhow::anyhow!(
171 "cuTensorMapEncodeTiled not present in libcuda — TMA needs a CUDA 12.0+ driver"
172 )
173 })?;
174 let mut map = TensorMap([0u8; 128]);
175 let rc = unsafe {
176 f(
177 map.0.as_mut_ptr(),
178 CU_TENSOR_MAP_DATA_TYPE_BFLOAT16,
179 2,
180 global.0,
181 global_dim.as_ptr(),
182 global_strides.as_ptr(),
183 box_dim.as_ptr(),
184 element_strides.as_ptr(),
185 CU_TENSOR_MAP_INTERLEAVE_NONE,
186 CU_TENSOR_MAP_SWIZZLE_NONE,
187 CU_TENSOR_MAP_L2_PROMOTION_L2_128B,
188 CU_TENSOR_MAP_FLOAT_OOB_FILL_NONE,
189 )
190 };
191 if rc != 0 {
192 bail!(
193 "cuTensorMapEncodeTiled failed: CUresult {rc} \
194 (rows={rows} cols={cols} stride={row_stride_elems} box={box_rows}x{box_cols})"
195 );
196 }
197 Ok(map)
198 }
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204
205 /// The alignment guards must reject BEFORE calling the driver — these run on
206 /// a box with no CUDA context, which is exactly the point: a misaligned
207 /// stride is a caller bug, not a driver outcome.
208 #[test]
209 fn misaligned_stride_is_rejected_without_touching_the_driver() {
210 // 5 bf16 elements = 10 bytes: not a multiple of 16.
211 let e = TensorMap::tiled_2d_bf16(DevicePtr(0x1000), 4, 5, 5, 2, 5).unwrap_err();
212 assert!(
213 e.to_string().contains("not 16-byte aligned"),
214 "expected a stride-alignment error, got: {e}"
215 );
216 }
217
218 #[test]
219 fn misaligned_address_is_rejected() {
220 let e = TensorMap::tiled_2d_bf16(DevicePtr(0x1004), 4, 8, 8, 2, 8).unwrap_err();
221 assert!(e.to_string().contains("not 16-byte aligned"), "got: {e}");
222 }
223
224 #[test]
225 fn a_stride_narrower_than_the_row_is_rejected() {
226 let e = TensorMap::tiled_2d_bf16(DevicePtr(0x1000), 4, 64, 32, 2, 64).unwrap_err();
227 assert!(e.to_string().contains("narrower than cols"), "got: {e}");
228 }
229
230 /// 128 bytes at 64-byte alignment is a hard driver requirement, and getting
231 /// it wrong is an invalid-argument at encode time or corruption at launch.
232 #[test]
233 fn the_descriptor_has_the_layout_the_driver_requires() {
234 assert_eq!(std::mem::size_of::<TensorMap>(), 128);
235 assert_eq!(std::mem::align_of::<TensorMap>(), 64);
236 }
237}