Skip to main content

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}