spark_runtime/
kernel_args.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Type-safe kernel argument builder for CUDA + Metal kernel launches.
4//!
5//! Replaces manual `Vec<*mut c_void>` construction with a builder
6//! pattern that prevents parameter type/order mismatches AND records
7//! per-arg type information so the metal backend can dispatch buffer
8//! args via `setBuffer:offset:atIndex:` and scalar args via
9//! `setBytes:length:atIndex:` (cuda's untyped `cuLaunchKernel` cannot
10//! distinguish the two; metal cannot conflate them).
11//!
12//! # Usage
13//!
14//! ```ignore
15//! KernelLaunch::new(gpu, kernel)
16//!     .grid([num_tokens, 1, 1])
17//!     .block([256, 1, 1])
18//!     .arg_ptr(input)
19//!     .arg_u32(hidden_size)
20//!     .arg_f32(eps)
21//!     .launch(stream)?;
22//! ```
23//!
24//! Internally, every arg is recorded with its kind (Buffer / Scalar)
25//! and its native byte width. `launch()` materializes a typed
26//! `KernelArg` slice and calls `GpuBackend::launch_typed`. The
27//! cuda backend's default `launch_typed` impl flattens that back
28//! into the legacy `void**` shape; the metal backend overrides
29//! `launch_typed` to thread the type info through to the encoder.
30
31use anyhow::Result;
32
33use crate::gpu::{DevicePtr, GpuBackend, KernelArg, KernelHandle};
34
35/// Per-arg metadata: which slot of `storage` it lives at, and how
36/// many native bytes it occupies. Buffer args set `is_buffer = true`
37/// and ignore `byte_len`. Scalar args set `is_buffer = false` and
38/// record `byte_len = sizeof::<T>()`.
39struct ArgKind {
40    is_buffer: bool,
41    /// Byte count for scalar args (4 for u32/i32/f32, 8 for u64, 128 for a
42    /// `CUtensorMap`). Unused when `is_buffer` is true. `u16`, not `u8`: a TMA
43    /// descriptor does not fit in 255 bytes' worth of headroom by much, and a
44    /// silently-wrapped length here is the same class of bug as the truncation
45    /// this type now guards against.
46    byte_len: u16,
47    /// Starting slot in `storage`. An arg is NOT one slot: a by-value struct
48    /// occupies `ceil(byte_len/8)` CONSECUTIVE slots, so `launch()` can no
49    /// longer index `storage` by the arg's position in `kinds`.
50    slot: u32,
51}
52
53/// Builder for type-safe kernel launches across CUDA + Metal.
54///
55/// Accumulates grid dimensions, block dimensions, and typed kernel
56/// arguments. `launch()` packages the args as `&[KernelArg]` and
57/// calls `GpuBackend::launch_typed`.
58pub struct KernelLaunch<'a> {
59    gpu: &'a dyn GpuBackend,
60    kernel: KernelHandle,
61    grid: [u32; 3],
62    block: [u32; 3],
63    shared_mem: u32,
64    /// Backing storage: each parameter's bytes stored in a u64 slot
65    /// (LE-packed for scalars; raw u64 GPU address for pointers).
66    /// Pointers into this vec remain stable because we never
67    /// reallocate after the initial capacity reservation.
68    storage: Vec<u64>,
69    /// Parallel array recording per-arg kind so `launch()` can build
70    /// a typed `KernelArg` slice.
71    kinds: Vec<ArgKind>,
72}
73
74impl<'a> KernelLaunch<'a> {
75    pub fn new(gpu: &'a dyn GpuBackend, kernel: KernelHandle) -> Self {
76        Self {
77            gpu,
78            kernel,
79            grid: [1, 1, 1],
80            block: [1, 1, 1],
81            shared_mem: 0,
82            storage: Vec::with_capacity(16),
83            kinds: Vec::with_capacity(16),
84        }
85    }
86
87    pub fn grid(mut self, grid: [u32; 3]) -> Self {
88        self.grid = grid;
89        self
90    }
91
92    pub fn block(mut self, block: [u32; 3]) -> Self {
93        self.block = block;
94        self
95    }
96
97    pub fn shared_mem(mut self, bytes: u32) -> Self {
98        self.shared_mem = bytes;
99        self
100    }
101
102    /// Add a DevicePtr (u64) argument.
103    pub fn arg_ptr(mut self, p: DevicePtr) -> Self {
104        let slot = self.storage.len() as u32;
105        self.storage.push(p.0);
106        self.kinds.push(ArgKind {
107            is_buffer: true,
108            byte_len: 0,
109            slot,
110        });
111        self
112    }
113
114    /// Add a 128-byte `CUtensorMap` by value, for a kernel parameter declared
115    /// `__grid_constant__ const CUtensorMap`.
116    ///
117    /// TMA descriptors are the one argument on this path that is not
118    /// pointer-or-scalar sized: the driver copies all 128 bytes into the
119    /// parameter buffer, so the bytes must land in `ceil(128/8) = 16`
120    /// CONSECUTIVE slots contributing ONE param entry. See
121    /// `gpu::pack_kernel_args`, and `a_128_byte_arg_is_not_truncated`.
122    pub fn arg_tensormap(mut self, map: &[u8; 128]) -> Self {
123        let slot = self.storage.len() as u32;
124        for c in map.chunks(8) {
125            let mut w = [0u8; 8];
126            w.copy_from_slice(c);
127            self.storage.push(u64::from_le_bytes(w));
128        }
129        self.kinds.push(ArgKind {
130            is_buffer: false,
131            byte_len: 128,
132            slot,
133        });
134        self
135    }
136
137    /// Add a u32 argument.
138    pub fn arg_u32(mut self, v: u32) -> Self {
139        let slot = self.storage.len() as u32;
140        self.storage.push(v as u64);
141        self.kinds.push(ArgKind {
142            is_buffer: false,
143            byte_len: 4,
144            slot,
145        });
146        self
147    }
148
149    /// Add a u64 argument.
150    pub fn arg_u64(mut self, v: u64) -> Self {
151        let slot = self.storage.len() as u32;
152        self.storage.push(v);
153        self.kinds.push(ArgKind {
154            is_buffer: false,
155            byte_len: 8,
156            slot,
157        });
158        self
159    }
160
161    /// Add an i32 argument.
162    pub fn arg_i32(mut self, v: i32) -> Self {
163        // Store as u64, preserving the i32 bits in the low 4 bytes.
164        let slot = self.storage.len() as u32;
165        self.storage.push(v as u32 as u64);
166        self.kinds.push(ArgKind {
167            is_buffer: false,
168            byte_len: 4,
169            slot,
170        });
171        self
172    }
173
174    /// Add an f32 argument.
175    pub fn arg_f32(mut self, v: f32) -> Self {
176        let slot = self.storage.len() as u32;
177        self.storage.push(f32::to_bits(v) as u64);
178        self.kinds.push(ArgKind {
179            is_buffer: false,
180            byte_len: 4,
181            slot,
182        });
183        self
184    }
185
186    /// Execute the kernel launch via `GpuBackend::launch_typed`.
187    ///
188    /// Builds a typed `KernelArg` slice from the recorded storage +
189    /// kinds. The cuda backend's default `launch_typed` flattens this
190    /// back into the legacy `void**` shape; the metal backend
191    /// overrides `launch_typed` to use `setBuffer:` / `setBytes:` per
192    /// arg. The storage vec is not reallocated between building the
193    /// args and launching, so all byte slices remain valid.
194    pub fn launch(self, stream: u64) -> Result<()> {
195        // Build typed args. The `&[u8]` slices borrow from `self.storage`
196        // (specifically the low N bytes of each u64 slot, LE-packed).
197        let mut args: Vec<KernelArg<'_>> = Vec::with_capacity(self.kinds.len());
198        for kind in self.kinds.iter() {
199            let slot = &self.storage[kind.slot as usize];
200            if kind.is_buffer {
201                args.push(KernelArg::Buffer(DevicePtr(*slot)));
202            } else {
203                // SAFETY: slot is a valid u64 in self.storage; we slice
204                // its first `byte_len` bytes (LE) and the slice's
205                // lifetime is bounded by the borrow of self.storage,
206                // which lives until the end of this function.
207                let bytes = unsafe {
208                    std::slice::from_raw_parts(
209                        slot as *const u64 as *const u8,
210                        kind.byte_len as usize,
211                    )
212                };
213                args.push(KernelArg::Bytes(bytes));
214            }
215        }
216        let r = self.gpu.launch_typed(
217            self.kernel,
218            self.grid,
219            self.block,
220            self.shared_mem,
221            stream,
222            &args,
223        );
224        // ATLAS_DEBUG_SYNC_KERNELS (PCND, default-off): synchronize after
225        // each launch so an async CUDA fault surfaces AT the culprit launch
226        // (with grid/block) instead of at a later, unrelated sync point.
227        // Diagnostic only — leave unset in production (one stream sync per
228        // launch is a large slowdown). With RUST_BACKTRACE=1 the propagated
229        // error pinpoints the calling op.
230        if r.is_ok() && self.gpu.debug_sync_kernels() {
231            self.gpu.synchronize(stream).map_err(|e| {
232                let bt = std::backtrace::Backtrace::force_capture();
233                anyhow::anyhow!(
234                    "ATLAS_DEBUG_SYNC_KERNELS: async GPU fault immediately after kernel launch \
235                     grid={:?} block={:?} shared_mem={}: {e}\nLAUNCH BACKTRACE:\n{bt}",
236                    self.grid,
237                    self.block,
238                    self.shared_mem
239                )
240            })?;
241        }
242        r
243    }
244}
245
246// `ATLAS_DEBUG_SYNC_KERNELS` is now resolved once when the backend is built
247// and read through `GpuBackend::debug_sync_kernels` — the launch path is far
248// too hot for a per-call getenv, and a static was the wrong way to avoid one.
249
250/// Convenience: divide and round up.
251pub fn div_ceil(a: u32, b: u32) -> u32 {
252    a.div_ceil(b)
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258    use crate::gpu::mock::MockGpuBackend;
259
260    #[test]
261    fn test_kernel_launch_builder() {
262        let gpu = MockGpuBackend::new();
263        let kernel = gpu.kernel("test", "test_kernel").unwrap();
264
265        let result = KernelLaunch::new(&gpu, kernel)
266            .grid([4, 1, 1])
267            .block([256, 1, 1])
268            .arg_ptr(DevicePtr(0x1000))
269            .arg_u32(42)
270            .arg_f32(1.5)
271            .launch(0);
272
273        assert!(result.is_ok());
274        assert_eq!(gpu.launch_count(), 1);
275    }
276
277    /// A 128-byte by-value struct (the `CUtensorMap` case) must survive the
278    /// launch path intact and occupy 16 CONSECUTIVE slots with ONE param entry.
279    ///
280    /// The packing this guards used to be `b.len().min(8)`: it truncated to the
281    /// first 8 bytes and launched anyway. That is unobservable from the caller
282    /// and produces a kernel silently reading garbage, so the test asserts the
283    /// bytes round-trip, not merely that the call succeeded.
284    #[test]
285    fn a_128_byte_arg_is_not_truncated() {
286        use crate::gpu::{KernelArg, pack_kernel_args};
287        let map: Vec<u8> = (0..128u16).map(|i| (i * 7 % 251) as u8).collect();
288        let args = [
289            KernelArg::Buffer(DevicePtr(0xDEAD_BEEF)),
290            KernelArg::Bytes(&map),
291            KernelArg::Bytes(&42u32.to_le_bytes()),
292        ];
293        let (storage, starts) = pack_kernel_args(&args);
294
295        assert_eq!(
296            starts.len(),
297            3,
298            "one param entry per argument, not per slot"
299        );
300        assert_eq!(starts, vec![0, 1, 17], "the map occupies slots 1..=16");
301        assert_eq!(storage.len(), 18);
302        assert_eq!(storage[0], 0xDEAD_BEEF);
303
304        // Every one of the 128 bytes must be readable, contiguously, from the
305        // map's starting slot — that is exactly what the kernel will do.
306        let round_trip: Vec<u8> = storage[starts[1]..starts[1] + 16]
307            .iter()
308            .flat_map(|w| w.to_le_bytes())
309            .collect();
310        assert_eq!(
311            round_trip, map,
312            "128-byte struct arg was corrupted or truncated"
313        );
314        assert_eq!(
315            storage[starts[2]] as u32, 42,
316            "the arg after it is still intact"
317        );
318    }
319
320    /// End-to-end through the BUILDER: a tensormap between two ordinary args
321    /// must not disturb either, and must present as one param entry.
322    ///
323    /// `launch()` used to index `storage` by an arg's POSITION in `kinds`, which
324    /// is only correct while every arg is exactly one slot. A 16-slot arg makes
325    /// that indexing silently read the wrong slot for every argument AFTER it.
326    #[test]
327    fn a_tensormap_arg_does_not_shift_the_args_around_it() {
328        let gpu = MockGpuBackend::new();
329        let kernel = gpu.kernel("test", "tma_kernel").unwrap();
330        let map = [0xABu8; 128];
331
332        let b = KernelLaunch::new(&gpu, kernel)
333            .arg_ptr(DevicePtr(0x1000))
334            .arg_tensormap(&map)
335            .arg_u32(7);
336
337        assert_eq!(b.kinds.len(), 3, "one param entry per arg, not per slot");
338        assert_eq!(b.storage.len(), 1 + 16 + 1);
339        assert_eq!(b.kinds[0].slot, 0);
340        assert_eq!(b.kinds[1].slot, 1);
341        assert_eq!(b.kinds[1].byte_len, 128);
342        assert_eq!(
343            b.kinds[2].slot, 17,
344            "the arg after the map must not be shifted"
345        );
346        assert_eq!(b.storage[b.kinds[2].slot as usize] as u32, 7);
347        assert!(b.launch(0).is_ok());
348    }
349
350    /// A zero-length byte arg still needs a slot to point at.
351    #[test]
352    fn an_empty_byte_arg_still_gets_one_slot() {
353        use crate::gpu::{KernelArg, pack_kernel_args};
354        let (storage, starts) =
355            pack_kernel_args(&[KernelArg::Bytes(&[]), KernelArg::Bytes(&[9u8])]);
356        assert_eq!(starts, vec![0, 1]);
357        assert_eq!(storage.len(), 2);
358        assert_eq!(storage[1], 9);
359    }
360
361    #[test]
362    fn test_div_ceil() {
363        assert_eq!(div_ceil(10, 3), 4);
364        assert_eq!(div_ceil(9, 3), 3);
365        assert_eq!(div_ceil(1, 256), 1);
366        assert_eq!(div_ceil(256, 256), 1);
367        assert_eq!(div_ceil(257, 256), 2);
368    }
369}