tensor_wasm_wasi_gpu/kernel_args.rs
1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Craton Software Company
3
4//! Typed argv lowering for the `wasi:cuda` `launch` host function.
5//!
6//! Guests writing GPU kernels through TensorWasm's `wasi-cuda` ABI pass kernel
7//! parameters as a single opaque byte buffer (`args_ptr`, `args_len`). To
8//! turn that buffer into the `void**`-style array CUDA's `cuLaunchKernel`
9//! expects, the host needs a frozen per-argument packing format.
10//!
11//! ## Wire format
12//!
13//! The buffer is a sequence of `(tag, value)` records. Each record starts
14//! with a single tag byte ([`ArgTag`]) followed by the value bytes in
15//! little-endian order. There is **no inter-argument padding** — the host
16//! reads exactly `tag.size()` bytes after each tag byte, which matches how
17//! a guest authored against this ABI would `memcpy` values into a `&mut
18//! [u8]`. (CUDA's `cuLaunchKernel` itself doesn't care about host-side
19//! padding because each arg pointer is independently addressed.)
20//!
21//! | Tag | Type | Value bytes | Wire size (incl. tag) |
22//! |-----|------|-------------|------------------------|
23//! | 0x01 | `i32` | 4 | 5 |
24//! | 0x02 | `i64` | 8 | 9 |
25//! | 0x03 | `f32` | 4 | 5 |
26//! | 0x04 | `f64` | 8 | 9 |
27//! | 0x05 | `u32` | 4 | 5 |
28//! | 0x06 | `u64` | 8 | 9 |
29//! | 0x07 | `ptr` | 4 (guest ptr) + 4 (guest byte len) | 9 |
30//!
31//! Pointer arguments carry both the guest-memory offset and the byte length
32//! the kernel will access. The host bounds-checks the `[ptr, ptr+len)`
33//! window against the caller's linear memory before producing any device
34//! pointer — the same posture as the rest of the wasi-cuda host fns. On
35//! CUDA builds the resolved host pointer feeds directly into the
36//! `cuLaunchKernel` argv (CUDA Unified Memory makes managed host pointers
37//! valid as device pointers); on no-CUDA builds the resolved pointer is
38//! captured for inspection but never dereferenced as a device address.
39//!
40//! ## Sanity caps
41//!
42//! - [`MAX_KERNEL_ARGS`] caps the number of records per launch.
43//! - [`MAX_KERNEL_ARGS_BYTES`] caps the total wire-format buffer size.
44//!
45//! Both caps exist so a malformed-but-in-bounds buffer cannot pin
46//! arbitrary host memory inside the parser. Buffers that exceed either
47//! cap surface as [`AbiError::KernelArgsUnsupported`], reusing the
48//! existing code for "the host won't accept this shape" — distinct from
49//! [`AbiError::InvalidArgs`] (the tag bytes are malformed) and from
50//! [`AbiError::InvalidPointer`] (the guest pointer is OOB).
51//!
52//! See `wit/wasi-cuda.wit` and `docs/RISKS.md` for the public contract.
53
54use crate::abi::AbiError;
55
56/// Hard cap on the number of argv records the host will accept per launch.
57///
58/// CUDA itself allows up to 4096 kernel parameters; we cap lower here so a
59/// malicious guest can't force the host to allocate a multi-megabyte
60/// metadata array before the launch is rejected. Most kernels take ≤ 16
61/// args; raising this is cheap if a real workload needs it.
62pub const MAX_KERNEL_ARGS: usize = 128;
63
64/// Hard cap on the total wire-format buffer length the host will parse.
65///
66/// At [`MAX_KERNEL_ARGS`] = 128 records of the widest type (9 bytes each)
67/// the buffer is at most 1152 bytes; we round up to 4 KiB so a guest
68/// emitting a slightly-extended format (future extra-tag bytes) still
69/// fits. Buffers above this cap return
70/// [`AbiError::KernelArgsUnsupported`].
71pub const MAX_KERNEL_ARGS_BYTES: usize = 4 * 1024;
72
73/// Tag byte identifying a single kernel-argument record's type.
74///
75/// The numeric values are part of the ABI: bumping a value is a breaking
76/// change. New tags are appended (next free: `0x08`).
77#[repr(u8)]
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum ArgTag {
80 /// 32-bit signed integer. Value bytes: `[i32 LE; 1]`.
81 I32 = 0x01,
82 /// 64-bit signed integer. Value bytes: `[i64 LE; 1]`.
83 I64 = 0x02,
84 /// 32-bit IEEE-754 float. Value bytes: `[f32 LE; 1]`.
85 F32 = 0x03,
86 /// 64-bit IEEE-754 float. Value bytes: `[f64 LE; 1]`.
87 F64 = 0x04,
88 /// 32-bit unsigned integer. Value bytes: `[u32 LE; 1]`.
89 U32 = 0x05,
90 /// 64-bit unsigned integer. Value bytes: `[u64 LE; 1]`.
91 U64 = 0x06,
92 /// Guest pointer + byte length. Value bytes: `[u32 LE; 2]` — the first
93 /// `u32` is the guest-memory offset, the second is the number of
94 /// bytes the kernel will touch starting at that offset.
95 Ptr = 0x07,
96}
97
98impl ArgTag {
99 /// Parse a single tag byte. Returns [`AbiError::InvalidArgs`] for any
100 /// byte value not currently assigned.
101 pub fn from_byte(b: u8) -> Result<Self, AbiError> {
102 Ok(match b {
103 0x01 => ArgTag::I32,
104 0x02 => ArgTag::I64,
105 0x03 => ArgTag::F32,
106 0x04 => ArgTag::F64,
107 0x05 => ArgTag::U32,
108 0x06 => ArgTag::U64,
109 0x07 => ArgTag::Ptr,
110 _ => return Err(AbiError::InvalidArgs),
111 })
112 }
113
114 /// Number of value bytes that follow the tag in the wire format.
115 pub const fn value_bytes(self) -> usize {
116 match self {
117 ArgTag::I32 | ArgTag::F32 | ArgTag::U32 => 4,
118 ArgTag::I64 | ArgTag::F64 | ArgTag::U64 => 8,
119 ArgTag::Ptr => 8, // u32 ptr + u32 len
120 }
121 }
122}
123
124/// A single fully-parsed and bounds-checked kernel argument.
125///
126/// `LoweredArg` is the host-side representation that flows into
127/// `cuLaunchKernel` (via [`build_kernel_param_storage`]) on CUDA builds
128/// and is recorded for inspection on no-CUDA builds.
129#[derive(Debug, Clone, PartialEq)]
130pub enum LoweredArg {
131 /// 32-bit signed scalar.
132 I32(i32),
133 /// 64-bit signed scalar.
134 I64(i64),
135 /// 32-bit float scalar.
136 F32(f32),
137 /// 64-bit float scalar.
138 F64(f64),
139 /// 32-bit unsigned scalar.
140 U32(u32),
141 /// 64-bit unsigned scalar.
142 U64(u64),
143 /// Pointer argument: a fully bounds-checked host pointer plus the
144 /// declared byte length and the original guest-memory offset.
145 ///
146 /// `host_ptr` is a raw pointer into the caller's linear memory; under
147 /// CUDA Unified Memory it is also a valid device address. The
148 /// `guest_offset` field is preserved for log lines / tests so a
149 /// pointer arg round-trips losslessly through parsing.
150 ///
151 /// `#[non_exhaustive]` blocks external struct-literal construction
152 /// — out-of-crate callers cannot stamp a `LoweredArg::Ptr { ... }`
153 /// expression with an arbitrary `host_ptr`. The launch path itself,
154 /// which lives inside this crate, retains full construction rights;
155 /// integration tests author pointer args via
156 /// [`LoweredArg::ptr_for_encoding`] (null placeholder pointer the
157 /// parser overwrites with the resolved one). This is defence-in-
158 /// depth on top of the comment that the field is "treat as opaque":
159 /// a guest-driven `memory.grow` between launch and any embedder
160 /// readback can dangle the host pointer, so the only safe consumer
161 /// is the launch path. Embedders observing the parsed argv take a
162 /// pointer-free [`LoweredArgSnapshot`] via
163 /// [`crate::host::WasiCudaContext::last_lowered_args`].
164 ///
165 /// Pattern-matching still works for external consumers using the
166 /// `..` rest pattern (`LoweredArg::Ptr { guest_offset, len, .. }`);
167 /// the rest pattern intentionally hides `host_ptr` at the public
168 /// boundary.
169 #[non_exhaustive]
170 Ptr {
171 /// Host-side raw pointer into the caller's linear memory.
172 ///
173 /// Treat as opaque — dereferencing is the caller's responsibility
174 /// and is unsafe in general (the underlying linear memory may
175 /// move on a `memory.grow`). The CUDA launch site captures the
176 /// pointer into a parameter slot and immediately hands it to
177 /// `cuLaunchKernel`, before any guest code can run.
178 host_ptr: *const u8,
179 /// Byte length the kernel will access.
180 len: u32,
181 /// Original guest-memory offset (for logging / tests).
182 guest_offset: u32,
183 },
184}
185
186// SAFETY: `LoweredArg::Ptr::host_ptr` is a raw pointer into Wasm linear
187// memory. We never share it across threads — the launch path moves it
188// straight into a `cuLaunchKernel` parameter slot, awaits the
189// `spawn_blocking(synchronize)`, then drops the `Vec<LoweredArg>` before
190// returning to the wasmtime fiber. Recording it under `Send` is
191// nevertheless safe because no concurrent reader exists by construction.
192//
193// `Sync` is deliberately NOT implemented: no current call site shares a
194// `&LoweredArg` across threads, and leaving `Sync` off prevents an
195// embedder from accidentally moving a `&LoweredArg` (and therefore the
196// raw `host_ptr` it carries) to another thread where a `memory.grow` on
197// the originating instance could dangle it under their feet.
198unsafe impl Send for LoweredArg {}
199
200/// Pointer-free, freely-`Send`/`Sync` snapshot of a [`LoweredArg`].
201///
202/// `LoweredArgSnapshot` is the shape the public observability surface
203/// ([`crate::host::WasiCudaContext::last_lowered_args`]) hands out. It
204/// mirrors every variant of [`LoweredArg`] except that the `Ptr` variant
205/// drops the raw `host_ptr` field — only the `guest_offset` and `len`
206/// the guest declared survive.
207///
208/// The redaction is deliberate, not cosmetic. The host-side raw pointer
209/// inside a `LoweredArg::Ptr` is only valid while the guest's linear
210/// memory has not been reallocated; a `memory.grow` between launch and
211/// readback would dangle it. Stripping the field at the public boundary
212/// makes that use-after-grow unrepresentable for embedders, and also
213/// removes the only reason `LoweredArg` itself cannot implement `Sync`.
214#[derive(Debug, Clone, PartialEq)]
215pub enum LoweredArgSnapshot {
216 /// 32-bit signed scalar.
217 I32(i32),
218 /// 64-bit signed scalar.
219 I64(i64),
220 /// 32-bit float scalar.
221 F32(f32),
222 /// 64-bit float scalar.
223 F64(f64),
224 /// 32-bit unsigned scalar.
225 U32(u32),
226 /// 64-bit unsigned scalar.
227 U64(u64),
228 /// Pointer argument with the host pointer redacted; only the
229 /// guest-declared `(guest_offset, len)` pair survives.
230 Ptr {
231 /// Byte length the kernel will access.
232 len: u32,
233 /// Original guest-memory offset (for logging / tests).
234 guest_offset: u32,
235 },
236}
237
238impl From<&LoweredArg> for LoweredArgSnapshot {
239 fn from(arg: &LoweredArg) -> Self {
240 match arg {
241 LoweredArg::I32(v) => LoweredArgSnapshot::I32(*v),
242 LoweredArg::I64(v) => LoweredArgSnapshot::I64(*v),
243 LoweredArg::F32(v) => LoweredArgSnapshot::F32(*v),
244 LoweredArg::F64(v) => LoweredArgSnapshot::F64(*v),
245 LoweredArg::U32(v) => LoweredArgSnapshot::U32(*v),
246 LoweredArg::U64(v) => LoweredArgSnapshot::U64(*v),
247 // `host_ptr` is intentionally dropped — see the type-level
248 // doc comment for the use-after-grow rationale.
249 LoweredArg::Ptr {
250 host_ptr: _,
251 len,
252 guest_offset,
253 } => LoweredArgSnapshot::Ptr {
254 len: *len,
255 guest_offset: *guest_offset,
256 },
257 }
258 }
259}
260
261impl LoweredArg {
262 /// Test/encoding helper: build a `LoweredArg::Ptr` carrying a null
263 /// host pointer.
264 ///
265 /// Out-of-crate callers (integration tests and embedders that want
266 /// to round-trip argv through [`encode_argv`]) cannot construct
267 /// `LoweredArg::Ptr` via struct-literal syntax because the variant
268 /// is `#[non_exhaustive]`; this constructor is the supported entry
269 /// point. `encode_argv` reads only `guest_offset` and `len`, so a
270 /// null placeholder is sufficient for the wire-format encoding
271 /// path; the launch path itself populates `host_ptr` via
272 /// [`parse_argv`].
273 pub fn ptr_for_encoding(guest_offset: u32, len: u32) -> Self {
274 LoweredArg::Ptr {
275 host_ptr: std::ptr::null(),
276 len,
277 guest_offset,
278 }
279 }
280}
281
282/// Parse a tagged-argv byte buffer into a host-side `Vec<LoweredArg>`.
283///
284/// `mem` is the caller's linear-memory snapshot (as exposed by
285/// `wasmtime::Memory::data`); pointer arguments are bounds-checked
286/// against it.
287///
288/// The parser is strict: it rejects any byte left over after the last
289/// record, refuses unknown tag bytes, and refuses an over-long buffer or
290/// too-many-args count. The intent is that a malformed buffer is detected
291/// here, before any CUDA call.
292///
293/// Errors:
294/// - [`AbiError::KernelArgsUnsupported`] — buffer exceeds the sanity caps
295/// ([`MAX_KERNEL_ARGS`] / [`MAX_KERNEL_ARGS_BYTES`]).
296/// - [`AbiError::InvalidArgs`] — buffer is malformed (unknown tag,
297/// trailing bytes, value bytes truncated against a tag).
298/// - [`AbiError::InvalidPointer`] — a pointer arg's `[ptr, ptr+len)`
299/// window lies outside `mem`.
300pub fn parse_argv(buf: &[u8], mem: &[u8]) -> Result<Vec<LoweredArg>, AbiError> {
301 if buf.len() > MAX_KERNEL_ARGS_BYTES {
302 return Err(AbiError::KernelArgsUnsupported);
303 }
304 let mut out: Vec<LoweredArg> = Vec::new();
305 let mut i = 0usize;
306 while i < buf.len() {
307 if out.len() >= MAX_KERNEL_ARGS {
308 return Err(AbiError::KernelArgsUnsupported);
309 }
310 let tag = ArgTag::from_byte(buf[i])?;
311 i += 1;
312 let need = tag.value_bytes();
313 if i.checked_add(need).map_or(true, |end| end > buf.len()) {
314 // Tag promised `need` bytes but the buffer is shorter — the
315 // record is truncated, the guest's packing is malformed.
316 return Err(AbiError::InvalidArgs);
317 }
318 let val = &buf[i..i + need];
319 i += need;
320 let arg = match tag {
321 ArgTag::I32 => {
322 let mut b = [0u8; 4];
323 b.copy_from_slice(val);
324 LoweredArg::I32(i32::from_le_bytes(b))
325 }
326 ArgTag::I64 => {
327 let mut b = [0u8; 8];
328 b.copy_from_slice(val);
329 LoweredArg::I64(i64::from_le_bytes(b))
330 }
331 ArgTag::F32 => {
332 let mut b = [0u8; 4];
333 b.copy_from_slice(val);
334 LoweredArg::F32(f32::from_le_bytes(b))
335 }
336 ArgTag::F64 => {
337 let mut b = [0u8; 8];
338 b.copy_from_slice(val);
339 LoweredArg::F64(f64::from_le_bytes(b))
340 }
341 ArgTag::U32 => {
342 let mut b = [0u8; 4];
343 b.copy_from_slice(val);
344 LoweredArg::U32(u32::from_le_bytes(b))
345 }
346 ArgTag::U64 => {
347 let mut b = [0u8; 8];
348 b.copy_from_slice(val);
349 LoweredArg::U64(u64::from_le_bytes(b))
350 }
351 ArgTag::Ptr => {
352 let mut p = [0u8; 4];
353 p.copy_from_slice(&val[0..4]);
354 let mut l = [0u8; 4];
355 l.copy_from_slice(&val[4..8]);
356 let guest_offset = u32::from_le_bytes(p);
357 let len = u32::from_le_bytes(l);
358 let start = guest_offset as usize;
359 let end = start
360 .checked_add(len as usize)
361 .ok_or(AbiError::InvalidPointer)?;
362 if end > mem.len() {
363 return Err(AbiError::InvalidPointer);
364 }
365 // SAFETY: `start` is in-bounds for `mem` (checked above);
366 // for a zero-length pointer the pointer is still a valid
367 // single-byte offset into the slice as long as
368 // `start <= mem.len()`. We use `as_ptr().add(start)` which
369 // requires that bound.
370 let host_ptr = unsafe { mem.as_ptr().add(start) };
371 LoweredArg::Ptr {
372 host_ptr,
373 len,
374 guest_offset,
375 }
376 }
377 };
378 out.push(arg);
379 }
380 Ok(out)
381}
382
383/// Storage backing the `void**` array `cuLaunchKernel` expects.
384///
385/// CUDA's `cuLaunchKernel` takes a `*mut *mut c_void` whose elements each
386/// point to the value bytes of a single argument. The lifetimes are
387/// awkward: the value bytes must outlive the launch call, and so must
388/// the pointer array. [`KernelParamStorage`] owns both so the launch
389/// site can take a `*mut *mut c_void` into it and pass that to
390/// `cuLaunchKernel` without leaking.
391///
392/// Currently only used on CUDA builds; the no-CUDA host path records the
393/// parsed [`Vec<LoweredArg>`] directly. The struct is still defined
394/// non-conditionally so the type checker can prove the no-CUDA build
395/// keeps the lowering free of CUDA-specific types.
396///
397/// # Layout (post v0.3.4)
398///
399/// A single contiguous `Vec<u8>` (`backing`) holds every scalar value
400/// in declaration order, aligned per its native type. `slots` is the
401/// pointer table CUDA reads through — each `*mut c_void` points to the
402/// start of one slot inside `backing`. A 16-arg kernel costs one
403/// `Vec<u8>` allocation plus one `Vec<*mut c_void>` allocation rather
404/// than 16 small `Box<[u8]>` allocations.
405///
406/// The single-buffer layout is sound because `cuLaunchKernel` reads
407/// each parameter through `kernelParams[i]` independently; the driver
408/// has no per-slot alignment requirement that depends on slots being
409/// distinct allocations. We still align each slot to
410/// `std::mem::align_of::<T>()` so a misaligned `f64` cannot read garbage
411/// on architectures that fault on unaligned loads.
412pub struct KernelParamStorage {
413 /// Contiguous backing buffer holding every scalar slot's value
414 /// bytes (or, for `Ptr` args, the `usize`-encoded resolved host
415 /// pointer — CUDA's argv is "address-of pointer", not "pointer").
416 /// Stored in a `Box<[u8]>` rather than a `Vec<u8>` so the buffer
417 /// cannot be reallocated after `slots` is populated — any reallocation
418 /// would dangle every pointer inside `slots`. We freeze the `Vec`
419 /// into a `Box<[u8]>` once we're done writing to it.
420 backing: Box<[u8]>,
421 /// Pointer slots in the order CUDA reads them. Each entry points
422 /// into `backing` at the slot's recorded offset.
423 slots: Vec<*mut std::ffi::c_void>,
424}
425
426// SAFETY: the pointers inside `slots` reference either bytes inside
427// `backing` (which the struct owns and never reallocates after
428// construction) or live wasm linear-memory bytes whose lifetime the
429// launch site enforces externally (the `Vec<LoweredArg>` it originated
430// from is held in the same stack frame). Like `LoweredArg`, we never
431// share these across threads.
432unsafe impl Send for KernelParamStorage {}
433
434impl KernelParamStorage {
435 /// Borrow the pointer slots as a `*mut *mut c_void` suitable for
436 /// `cuLaunchKernel`.
437 pub fn as_ptr(&mut self) -> *mut *mut std::ffi::c_void {
438 self.slots.as_mut_ptr()
439 }
440
441 /// Number of arguments in this storage.
442 pub fn len(&self) -> usize {
443 self.slots.len()
444 }
445
446 /// True when the storage carries no arguments.
447 pub fn is_empty(&self) -> bool {
448 self.slots.is_empty()
449 }
450
451 /// Borrow the contiguous backing buffer that holds every slot's
452 /// value bytes. Exposed (crate-public) for the regression test that
453 /// asserts all slot pointers land inside one allocation; not part
454 /// of the launch-time hot path.
455 #[doc(hidden)]
456 pub fn backing(&self) -> &[u8] {
457 &self.backing
458 }
459
460 /// Borrow the pointer table as a slice. Exposed for tests; the
461 /// launch path uses [`KernelParamStorage::as_ptr`].
462 #[doc(hidden)]
463 pub fn slot_ptrs(&self) -> &[*mut std::ffi::c_void] {
464 &self.slots
465 }
466}
467
468/// Build a [`KernelParamStorage`] from a previously-parsed `Vec<LoweredArg>`.
469///
470/// On CUDA builds the resulting storage is handed directly to
471/// `cuLaunchKernel`; on no-CUDA builds it is only useful as a sanity
472/// check that the layout compiles cleanly across both feature
473/// configurations.
474///
475/// # Allocation profile
476///
477/// One `Vec<u8>` (sized to fit every aligned slot) plus one
478/// `Vec<*mut c_void>` of `args.len()` pointer slots. A 16-arg kernel
479/// therefore costs two allocations total, down from 17 on the
480/// pre-v0.3.4 layout that boxed each scalar separately.
481pub fn build_kernel_param_storage(args: &[LoweredArg]) -> KernelParamStorage {
482 // Pass 1: compute the offset of each slot inside `backing`,
483 // padding for the slot's native alignment. We keep the offsets in a
484 // `Vec<usize>` rather than dropping straight into `slots: Vec<*mut
485 // c_void>` because `backing` may move while we are still pushing
486 // bytes into it; the offsets stay valid across that move, and we
487 // resolve them to pointers exactly once at the end (after `backing`
488 // is frozen into a `Box<[u8]>`).
489 //
490 // Note on CUDA semantics for `Ptr`: CUDA expects the *address of*
491 // the device pointer in each argv slot. We stash the pointer's
492 // `usize` bytes in `backing` and point the slot at *that*. Under
493 // CUDA Unified Memory `host_ptr` doubles as a device address; for
494 // non-UVM allocations the guest would have to call `cuMemAlloc`
495 // separately (out of scope for v0.2 — see `docs/RISKS.md`).
496 //
497 // PERF (T23): pre-size `backing` so a typical 8-arg kernel does not
498 // pay 2-3 reallocations as the Vec grows from cap 0. We walk `args`
499 // once to compute a safe upper bound on the post-padding size:
500 // each slot occupies at most `align-1` padding bytes (worst case)
501 // plus its native size. This over-counts the padding (the actual
502 // padding depends on running totals), but `into_boxed_slice` later
503 // drops the unused tail capacity so the over-allocation is purely
504 // a startup tax, paid once.
505 let estimated_cap: usize = args
506 .iter()
507 .map(|a| match a {
508 LoweredArg::I32(_) | LoweredArg::F32(_) | LoweredArg::U32(_) => {
509 std::mem::align_of::<u32>() - 1 + 4
510 }
511 LoweredArg::I64(_) | LoweredArg::F64(_) | LoweredArg::U64(_) => {
512 std::mem::align_of::<u64>() - 1 + 8
513 }
514 LoweredArg::Ptr { .. } => {
515 std::mem::align_of::<usize>() - 1 + std::mem::size_of::<usize>()
516 }
517 })
518 .sum();
519 let mut backing: Vec<u8> = Vec::with_capacity(estimated_cap);
520 let mut offsets: Vec<usize> = Vec::with_capacity(args.len());
521
522 /// Pad `backing.len()` up to the next multiple of `align`, then
523 /// record the resulting offset as a new slot, then extend `backing`
524 /// with `bytes`. Returns the slot's start offset.
525 fn push_slot(backing: &mut Vec<u8>, offsets: &mut Vec<usize>, align: usize, bytes: &[u8]) {
526 let unpadded = backing.len();
527 // Round up to the next multiple of `align`. `align` is always a
528 // power of two for the types we handle (`align_of::<T>()`), so
529 // the bit-trick is safe.
530 let padding = unpadded.wrapping_neg() & (align - 1);
531 backing.resize(unpadded + padding, 0);
532 let offset = backing.len();
533 backing.extend_from_slice(bytes);
534 offsets.push(offset);
535 }
536
537 for a in args {
538 match a {
539 LoweredArg::I32(v) => push_slot(
540 &mut backing,
541 &mut offsets,
542 std::mem::align_of::<i32>(),
543 &v.to_ne_bytes(),
544 ),
545 LoweredArg::I64(v) => push_slot(
546 &mut backing,
547 &mut offsets,
548 std::mem::align_of::<i64>(),
549 &v.to_ne_bytes(),
550 ),
551 LoweredArg::F32(v) => push_slot(
552 &mut backing,
553 &mut offsets,
554 std::mem::align_of::<f32>(),
555 &v.to_ne_bytes(),
556 ),
557 LoweredArg::F64(v) => push_slot(
558 &mut backing,
559 &mut offsets,
560 std::mem::align_of::<f64>(),
561 &v.to_ne_bytes(),
562 ),
563 LoweredArg::U32(v) => push_slot(
564 &mut backing,
565 &mut offsets,
566 std::mem::align_of::<u32>(),
567 &v.to_ne_bytes(),
568 ),
569 LoweredArg::U64(v) => push_slot(
570 &mut backing,
571 &mut offsets,
572 std::mem::align_of::<u64>(),
573 &v.to_ne_bytes(),
574 ),
575 LoweredArg::Ptr { host_ptr, .. } => {
576 // CUDA expects the address-of-pointer; we store the
577 // resolved host pointer's `usize` bytes in `backing` so
578 // the slot can point at *that*.
579 let as_usize = *host_ptr as usize;
580 push_slot(
581 &mut backing,
582 &mut offsets,
583 std::mem::align_of::<usize>(),
584 &as_usize.to_ne_bytes(),
585 );
586 }
587 }
588 }
589
590 // Freeze the backing buffer. `into_boxed_slice` drops the unused
591 // tail capacity but does not move the bytes — the slice keeps the
592 // same data pointer, so the offsets we computed above remain
593 // correct. From here on, `backing` is read-only and its data
594 // pointer is stable for the lifetime of the `KernelParamStorage`.
595 let backing: Box<[u8]> = backing.into_boxed_slice();
596
597 // Pass 2: resolve each offset to a raw pointer into `backing`. We
598 // do this after freezing the buffer so the pointers are guaranteed
599 // not to dangle.
600 let base = backing.as_ptr() as *mut u8;
601 let slots: Vec<*mut std::ffi::c_void> = offsets
602 .iter()
603 .map(|&off| {
604 // SAFETY: every offset was produced by `push_slot` and is
605 // at most `backing.len()`. The resulting pointer is
606 // in-bounds for the `Box<[u8]>` (and one-past-end for a
607 // hypothetical zero-byte slot, which the type system would
608 // not let us reach but the alignment doesn't preclude).
609 unsafe { base.add(off) as *mut std::ffi::c_void }
610 })
611 .collect();
612
613 KernelParamStorage { backing, slots }
614}
615
616/// Helper for tests and host-stub paths: encode a sequence of
617/// [`LoweredArg`] back into the wire format.
618///
619/// Inverse of [`parse_argv`] for scalar values; for `Ptr` it re-emits the
620/// `guest_offset` / `len` pair (the resolved `host_ptr` is host-side
621/// state and does not round-trip). This helper makes integration tests
622/// readable — they can author argv as `vec![LoweredArg::I32(1), ...]`,
623/// encode it, and feed the bytes through the launch path.
624pub fn encode_argv(args: &[LoweredArg]) -> Vec<u8> {
625 let mut out = Vec::new();
626 for a in args {
627 match a {
628 LoweredArg::I32(v) => {
629 out.push(ArgTag::I32 as u8);
630 out.extend_from_slice(&v.to_le_bytes());
631 }
632 LoweredArg::I64(v) => {
633 out.push(ArgTag::I64 as u8);
634 out.extend_from_slice(&v.to_le_bytes());
635 }
636 LoweredArg::F32(v) => {
637 out.push(ArgTag::F32 as u8);
638 out.extend_from_slice(&v.to_le_bytes());
639 }
640 LoweredArg::F64(v) => {
641 out.push(ArgTag::F64 as u8);
642 out.extend_from_slice(&v.to_le_bytes());
643 }
644 LoweredArg::U32(v) => {
645 out.push(ArgTag::U32 as u8);
646 out.extend_from_slice(&v.to_le_bytes());
647 }
648 LoweredArg::U64(v) => {
649 out.push(ArgTag::U64 as u8);
650 out.extend_from_slice(&v.to_le_bytes());
651 }
652 LoweredArg::Ptr {
653 guest_offset, len, ..
654 } => {
655 out.push(ArgTag::Ptr as u8);
656 out.extend_from_slice(&guest_offset.to_le_bytes());
657 out.extend_from_slice(&len.to_le_bytes());
658 }
659 }
660 }
661 out
662}
663
664#[cfg(test)]
665mod tests {
666 use super::*;
667
668 /// Backing slice big enough that any in-test pointer arg has room.
669 /// We use 4 KiB which exceeds the per-buffer cap but is fine for `mem`.
670 fn fake_mem() -> Vec<u8> {
671 vec![0u8; 4096]
672 }
673
674 #[test]
675 fn empty_buffer_parses_to_empty_vec() {
676 let mem = fake_mem();
677 let out = parse_argv(&[], &mem).unwrap();
678 assert!(out.is_empty());
679 }
680
681 #[test]
682 fn scalar_argv_round_trips() {
683 let mem = fake_mem();
684 let args = vec![
685 LoweredArg::I32(-7),
686 LoweredArg::U32(0xDEAD_BEEF),
687 LoweredArg::I64(-1_000_000_000_000),
688 LoweredArg::U64(0x00C0_FFEE_BABE_F00D),
689 LoweredArg::F32(core::f32::consts::PI),
690 LoweredArg::F64(core::f64::consts::E),
691 ];
692 let buf = encode_argv(&args);
693 let parsed = parse_argv(&buf, &mem).unwrap();
694 assert_eq!(parsed.len(), args.len());
695 assert_eq!(parsed, args);
696 }
697
698 #[test]
699 fn pointer_argv_resolves_host_ptr_and_records_offset() {
700 let mem = fake_mem();
701 let args = vec![LoweredArg::Ptr {
702 host_ptr: std::ptr::null(),
703 len: 128,
704 guest_offset: 64,
705 }];
706 let buf = encode_argv(&args);
707 let parsed = parse_argv(&buf, &mem).unwrap();
708 assert_eq!(parsed.len(), 1);
709 match &parsed[0] {
710 LoweredArg::Ptr {
711 host_ptr,
712 len,
713 guest_offset,
714 } => {
715 assert_eq!(*len, 128);
716 assert_eq!(*guest_offset, 64);
717 // Resolved host pointer is mem.as_ptr() + 64.
718 let expected = unsafe { mem.as_ptr().add(64) };
719 assert_eq!(*host_ptr, expected);
720 }
721 other => panic!("expected Ptr, got {other:?}"),
722 }
723 }
724
725 #[test]
726 fn mixed_argv_preserves_order() {
727 let mem = fake_mem();
728 let args = vec![
729 LoweredArg::I32(11),
730 LoweredArg::Ptr {
731 host_ptr: std::ptr::null(),
732 len: 16,
733 guest_offset: 32,
734 },
735 LoweredArg::F64(1.5),
736 LoweredArg::Ptr {
737 host_ptr: std::ptr::null(),
738 len: 8,
739 guest_offset: 1024,
740 },
741 LoweredArg::U64(42),
742 ];
743 let buf = encode_argv(&args);
744 let parsed = parse_argv(&buf, &mem).unwrap();
745 assert_eq!(parsed.len(), 5);
746 // Spot-check non-pointer fields directly; verify pointers by
747 // their preserved guest_offset.
748 assert!(matches!(parsed[0], LoweredArg::I32(11)));
749 assert!(matches!(parsed[2], LoweredArg::F64(v) if v == 1.5));
750 assert!(matches!(parsed[4], LoweredArg::U64(42)));
751 match &parsed[1] {
752 LoweredArg::Ptr {
753 guest_offset, len, ..
754 } => {
755 assert_eq!(*guest_offset, 32);
756 assert_eq!(*len, 16);
757 }
758 _ => panic!("idx 1 not Ptr"),
759 }
760 match &parsed[3] {
761 LoweredArg::Ptr {
762 guest_offset, len, ..
763 } => {
764 assert_eq!(*guest_offset, 1024);
765 assert_eq!(*len, 8);
766 }
767 _ => panic!("idx 3 not Ptr"),
768 }
769 }
770
771 #[test]
772 fn unknown_tag_byte_returns_invalid_args() {
773 let mem = fake_mem();
774 // 0xFF is not assigned. Expect InvalidArgs, NOT KernelArgsUnsupported.
775 let err = parse_argv(&[0xFF, 0, 0, 0, 0], &mem).unwrap_err();
776 assert_eq!(err, AbiError::InvalidArgs);
777 }
778
779 #[test]
780 fn truncated_record_returns_invalid_args() {
781 let mem = fake_mem();
782 // Tag promises 8 bytes (I64) but only 3 follow.
783 let buf = [ArgTag::I64 as u8, 1, 2, 3];
784 let err = parse_argv(&buf, &mem).unwrap_err();
785 assert_eq!(err, AbiError::InvalidArgs);
786 }
787
788 #[test]
789 fn oversized_buffer_returns_kernel_args_unsupported() {
790 let mem = fake_mem();
791 let buf = vec![0u8; MAX_KERNEL_ARGS_BYTES + 1];
792 let err = parse_argv(&buf, &mem).unwrap_err();
793 assert_eq!(err, AbiError::KernelArgsUnsupported);
794 }
795
796 #[test]
797 fn too_many_args_returns_kernel_args_unsupported() {
798 // Pack `MAX_KERNEL_ARGS + 1` I32 records; each takes 5 bytes, so
799 // the total stays under MAX_KERNEL_ARGS_BYTES (= 4096) for the
800 // currently-defined caps (128 * 5 = 640 bytes). The parser must
801 // refuse the (cap+1)-th record explicitly before it lowers.
802 let mem = fake_mem();
803 let one = vec![ArgTag::I32 as u8, 0, 0, 0, 0];
804 let mut buf = Vec::new();
805 for _ in 0..(MAX_KERNEL_ARGS + 1) {
806 buf.extend_from_slice(&one);
807 }
808 // Guard the assumption above: stay under the byte cap.
809 assert!(buf.len() <= MAX_KERNEL_ARGS_BYTES);
810 let err = parse_argv(&buf, &mem).unwrap_err();
811 assert_eq!(err, AbiError::KernelArgsUnsupported);
812 }
813
814 #[test]
815 fn pointer_out_of_bounds_returns_invalid_pointer() {
816 let mem = vec![0u8; 32];
817 let args = vec![LoweredArg::Ptr {
818 host_ptr: std::ptr::null(),
819 len: 16,
820 guest_offset: 24, // [24, 40) ⊄ [0, 32)
821 }];
822 let buf = encode_argv(&args);
823 let err = parse_argv(&buf, &mem).unwrap_err();
824 assert_eq!(err, AbiError::InvalidPointer);
825 }
826
827 #[test]
828 fn pointer_offset_overflow_returns_invalid_pointer() {
829 let mem = vec![0u8; 32];
830 // guest_offset = u32::MAX, len = 1024 — start + len overflows
831 // u32 → caught by the checked_add in parse_argv.
832 let args = vec![LoweredArg::Ptr {
833 host_ptr: std::ptr::null(),
834 len: 1024,
835 guest_offset: u32::MAX,
836 }];
837 let buf = encode_argv(&args);
838 let err = parse_argv(&buf, &mem).unwrap_err();
839 assert_eq!(err, AbiError::InvalidPointer);
840 }
841
842 #[test]
843 fn zero_length_pointer_at_memory_end_is_allowed() {
844 // A common safe pattern: zero-length pointer pointing at one-past-end.
845 let mem = vec![0u8; 32];
846 let args = vec![LoweredArg::Ptr {
847 host_ptr: std::ptr::null(),
848 len: 0,
849 guest_offset: 32,
850 }];
851 let buf = encode_argv(&args);
852 let parsed = parse_argv(&buf, &mem).unwrap();
853 assert_eq!(parsed.len(), 1);
854 }
855
856 #[test]
857 fn build_kernel_param_storage_slot_count_matches_args() {
858 let args = vec![
859 LoweredArg::I32(1),
860 LoweredArg::F64(2.0),
861 LoweredArg::Ptr {
862 host_ptr: std::ptr::null(),
863 len: 8,
864 guest_offset: 0,
865 },
866 ];
867 let storage = build_kernel_param_storage(&args);
868 assert_eq!(storage.len(), 3);
869 assert!(!storage.is_empty());
870 }
871
872 #[test]
873 fn build_kernel_param_storage_encodes_bytes_and_alignment() {
874 // Byte-level guard (not just slot count): a mixed scalar + ptr argv
875 // must land each value in `backing()` natively-aligned and encoded
876 // with the platform's native byte order (the impl uses
877 // `to_ne_bytes`). We recover each slot's offset from `slot_ptrs()`
878 // relative to `backing().as_ptr()`, then check both the alignment of
879 // that offset and the bytes stored there.
880 let i32_val: i32 = -0x0102_0304;
881 let f64_val: f64 = 1234.5;
882 let u64_val: u64 = 0x00C0_FFEE_BABE_F00D;
883 // ptr_for_encoding stores a null host pointer; the slot therefore
884 // holds `0usize` worth of native bytes.
885 let args = vec![
886 LoweredArg::I32(i32_val),
887 LoweredArg::F64(f64_val),
888 LoweredArg::ptr_for_encoding(/* guest_offset */ 64, /* len */ 16),
889 LoweredArg::U64(u64_val),
890 ];
891
892 let storage = build_kernel_param_storage(&args);
893 assert_eq!(storage.len(), 4);
894
895 let backing = storage.backing();
896 let base = backing.as_ptr() as usize;
897 let slots = storage.slot_ptrs();
898 assert_eq!(slots.len(), 4);
899
900 // Resolve each slot pointer back to an offset inside `backing`.
901 let offset_of = |i: usize| -> usize {
902 let p = slots[i] as usize;
903 assert!(
904 p >= base && p <= base + backing.len(),
905 "slot {i} pointer escapes backing buffer"
906 );
907 p - base
908 };
909
910 let read = |off: usize, n: usize| -> &[u8] { &backing[off..off + n] };
911
912 // Slot 0: i32, 4-byte aligned, native bytes.
913 let off0 = offset_of(0);
914 assert_eq!(off0 % std::mem::align_of::<i32>(), 0, "i32 slot misaligned");
915 assert_eq!(read(off0, 4), &i32_val.to_ne_bytes());
916
917 // Slot 1: f64, 8-byte aligned, native bytes.
918 let off1 = offset_of(1);
919 assert_eq!(off1 % std::mem::align_of::<f64>(), 0, "f64 slot misaligned");
920 assert_eq!(read(off1, 8), &f64_val.to_ne_bytes());
921
922 // Slot 2: ptr — stores the resolved host pointer's usize bytes.
923 // For a null placeholder that is `0usize`.
924 let off2 = offset_of(2);
925 assert_eq!(
926 off2 % std::mem::align_of::<usize>(),
927 0,
928 "ptr slot misaligned"
929 );
930 assert_eq!(
931 read(off2, std::mem::size_of::<usize>()),
932 &(0usize).to_ne_bytes()
933 );
934
935 // Slot 3: u64, 8-byte aligned, native bytes.
936 let off3 = offset_of(3);
937 assert_eq!(off3 % std::mem::align_of::<u64>(), 0, "u64 slot misaligned");
938 assert_eq!(read(off3, 8), &u64_val.to_ne_bytes());
939
940 // Slots must be laid out in declaration order with no overlap: each
941 // slot's offset is strictly greater than the previous slot's end.
942 assert!(off1 >= off0 + 4, "f64 slot overlaps i32 slot");
943 assert!(off2 >= off1 + 8, "ptr slot overlaps f64 slot");
944 assert!(
945 off3 >= off2 + std::mem::size_of::<usize>(),
946 "u64 slot overlaps ptr slot"
947 );
948 }
949
950 #[test]
951 fn arg_tag_value_bytes_pinned() {
952 // Wire-format guard: bumping any of these is a breaking change.
953 assert_eq!(ArgTag::I32.value_bytes(), 4);
954 assert_eq!(ArgTag::I64.value_bytes(), 8);
955 assert_eq!(ArgTag::F32.value_bytes(), 4);
956 assert_eq!(ArgTag::F64.value_bytes(), 8);
957 assert_eq!(ArgTag::U32.value_bytes(), 4);
958 assert_eq!(ArgTag::U64.value_bytes(), 8);
959 assert_eq!(ArgTag::Ptr.value_bytes(), 8);
960 }
961
962 #[test]
963 fn arg_tag_byte_values_pinned() {
964 // ABI guard: these constants are part of the on-the-wire format.
965 assert_eq!(ArgTag::I32 as u8, 0x01);
966 assert_eq!(ArgTag::I64 as u8, 0x02);
967 assert_eq!(ArgTag::F32 as u8, 0x03);
968 assert_eq!(ArgTag::F64 as u8, 0x04);
969 assert_eq!(ArgTag::U32 as u8, 0x05);
970 assert_eq!(ArgTag::U64 as u8, 0x06);
971 assert_eq!(ArgTag::Ptr as u8, 0x07);
972 }
973}