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}