Skip to main content

tensor_wasm_wasi_gpu/
abi.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Craton Software Company
3
4//! ABI for the `wasi-cuda` extension exposed to Wasm modules.
5//!
6//! The host functions live in a single module named [`MODULE`]. Each function
7//! takes integer / pointer-as-i32 arguments compatible with the canonical
8//! WASI ABI. Pointer arguments are interpreted as offsets within the
9//! caller's Wasm linear memory; on the host side they are translated to
10//! native pointers via the `caller.get_export("memory")` call.
11//!
12//! See `wit/wasi-cuda.wit` at the workspace root for the equivalent
13//! Component-Model interface description (`wasi:cuda/host@0.2.0`).
14
15/// Wasm module name to import host functions from: `wasi:cuda/host@0.2.0`.
16///
17/// The version segment is kept in lockstep with the `package` declaration in
18/// `wit/wasi-cuda.wit`; bumping one without the other will cause guests
19/// generated from the WIT to fail to link against this host.
20pub const MODULE: &str = "wasi:cuda/host@0.2.0";
21
22/// Function name: `load_ptx(ptx_ptr, ptx_len, entry_ptr, entry_len) -> i64`
23///
24/// Returns a non-negative [`KernelId`](tensor_wasm_core::types::KernelId) on success,
25/// or a negative [`AbiError`] code on failure.
26pub const FN_LOAD_PTX: &str = "wasi_cuda_load_ptx";
27
28/// Function name: `launch(kernel_id, grid_x, grid_y, grid_z, block_x, block_y, block_z, shared_mem, args_ptr, args_len) -> i32`
29///
30/// Returns 0 on success, a negative [`AbiError`] on failure.
31pub const FN_LAUNCH: &str = "wasi_cuda_launch";
32
33/// Function name: `sync() -> i32`. Returns 0 on success, negative on failure.
34pub const FN_SYNC: &str = "wasi_cuda_sync";
35
36/// Function name: `last_error_ptr() -> i32`.
37///
38/// **Deprecated**: superseded by [`FN_LAST_ERROR_COPY`]. The original design
39/// returned a pointer into a host-allocated Wasm-memory buffer, but that
40/// required coordination with the guest's allocator. The constant is kept
41/// for ABI / backwards-compat reasons but the function is no longer
42/// registered with the linker — callers must instead use
43/// [`FN_LAST_ERROR_LEN`] to learn the size and [`FN_LAST_ERROR_COPY`] to
44/// receive the bytes into a guest-supplied buffer.
45pub const FN_LAST_ERROR_PTR: &str = "wasi_cuda_last_error_ptr";
46
47/// Function name: `last_error_len() -> i32`. Length of the most recent error
48/// message in bytes (excluding the trailing NUL).
49pub const FN_LAST_ERROR_LEN: &str = "wasi_cuda_last_error_len";
50
51/// Function name: `last_error_copy(dst_ptr, dst_len) -> i32`. Copies the
52/// most recent error message (without NUL terminator) into `[dst_ptr,
53/// dst_ptr+dst_len)` in the caller's linear memory.
54///
55/// Return values:
56/// - `n > 0`: number of bytes written (clamped to `min(error_len, dst_len)`).
57/// - `0`: no error is currently recorded, or `dst_len == 0`.
58/// - `-2` ([`AbiError::InvalidPointer`]): `(dst_ptr, dst_len)` is invalid
59///   (out of bounds, negative, or overflows) or the underlying memory
60///   write failed. Crucially this is distinct from `0`: a guest that sees
61///   `-2` knows it must fix its buffer rather than assume "no error."
62pub const FN_LAST_ERROR_COPY: &str = "wasi_cuda_last_error_copy";
63
64/// Function name: `alloc(size_lo, size_hi) -> i64`.
65///
66/// Allocates an explicit device buffer of `size` bytes (the `u64` size is
67/// split into two i32 halves on the raw ABI to fit the i32-only import
68/// shape; the Component-Model binding sees a single `u64`). Returns a
69/// non-negative device-buffer handle on success, or a negative
70/// [`AbiError`] code on failure. Unlike the Unified-Memory pointer path used
71/// by `launch`, the returned handle names a discrete device allocation that
72/// works on any CUDA host.
73pub const FN_ALLOC: &str = "wasi_cuda_alloc";
74
75/// Function name: `free(handle_lo, handle_hi) -> i32`. Releases a buffer
76/// previously returned by [`FN_ALLOC`]. Returns 0 on success, a negative
77/// [`AbiError`] on failure ([`AbiError::InvalidHandle`] for an unknown or
78/// cross-owner handle).
79pub const FN_FREE: &str = "wasi_cuda_free";
80
81/// Function name: `memcpy_h2d(handle_lo, handle_hi, src_ptr, len) -> i32`.
82/// Copies `len` bytes from the guest linear-memory region `[src_ptr,
83/// src_ptr+len)` into the device buffer named by `handle`. Returns 0 on
84/// success, a negative [`AbiError`] on failure.
85pub const FN_MEMCPY_H2D: &str = "wasi_cuda_memcpy_h2d";
86
87/// Function name: `memcpy_d2h(dst_ptr, handle_lo, handle_hi, len) -> i32`.
88/// Copies `len` bytes from the device buffer named by `handle` into the
89/// guest linear-memory region `[dst_ptr, dst_ptr+len)`. Returns 0 on
90/// success, a negative [`AbiError`] on failure.
91pub const FN_MEMCPY_D2H: &str = "wasi_cuda_memcpy_d2h";
92
93/// Negative i32 status codes returned by the wasi-cuda host functions.
94///
95/// These are stable across TensorWasm versions; client code may match on the
96/// numeric value if it needs to.
97#[repr(i32)]
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum AbiError {
100    /// CUDA is not available on this host (no toolkit / no driver).
101    NotAvailable = -1,
102    /// A pointer / length pair pointed outside the caller's linear memory.
103    InvalidPointer = -2,
104    /// Wasi-cuda was passed a [`KernelId`](tensor_wasm_core::types::KernelId) that
105    /// is not registered (or that belongs to another instance).
106    InvalidKernel = -3,
107    /// `load_ptx` was called with bytes that `ptxas` rejected.
108    MalformedPtx = -4,
109    /// The CUDA driver returned an error during a launch / sync.
110    LaunchFailed = -5,
111    /// Resource limits (max kernels per instance, etc.) were exceeded.
112    QuotaExceeded = -6,
113    /// Generic internal error.
114    Internal = -7,
115    /// Launch grid / block dimensions exceeded hardware caps or were
116    /// non-positive. Returned by the host *before* any CUDA driver call,
117    /// allowing the guest to distinguish a launch-shape bug from a driver
118    /// error.
119    InvalidDimensions = -8,
120    /// Caller passed a structurally invalid argument that is neither a
121    /// memory-region issue nor a dimensions issue. Currently used for
122    /// non-UTF8 entry names in `load_ptx`.
123    InvalidArgs = -9,
124    /// Caller passed a well-formed, in-bounds kernel-argument buffer
125    /// that exceeds the host's sanity caps — total argv bytes greater
126    /// than [`MAX_KERNEL_ARGS_BYTES`](crate::kernel_args::MAX_KERNEL_ARGS_BYTES)
127    /// (4 KiB) or more than
128    /// [`MAX_KERNEL_ARGS`](crate::kernel_args::MAX_KERNEL_ARGS) (128)
129    /// tagged records. Since v0.2.0 (W1.1) the typed-argv lane accepts
130    /// arbitrary scalar + pointer argv below those caps; this code is
131    /// reserved for cap busts and is kept distinct from
132    /// [`AbiError::InvalidArgs`] so a guest can tell "your input shape
133    /// is too big for the host to accept" from "your input bytes are
134    /// malformed." See `wit/wasi-cuda.wit` and
135    /// [`crate::kernel_args::parse_argv`] for the contract.
136    KernelArgsUnsupported = -10,
137    /// A device-memory handle (from `alloc`) was passed to `free` /
138    /// `memcpy-h2d` / `memcpy-d2h` that is not registered, or that belongs
139    /// to another instance. Mirrors [`AbiError::InvalidKernel`] for the
140    /// kernel registry: a guest cannot forge another instance's device
141    /// buffer handle. See [`crate::device_mem::DeviceMemRegistry`].
142    InvalidHandle = -11,
143}
144
145impl AbiError {
146    /// Convert to the wire i32 code.
147    pub const fn code(self) -> i32 {
148        self as i32
149    }
150
151    /// Stable, human-readable name (used for log fields).
152    pub fn name(self) -> &'static str {
153        match self {
154            AbiError::NotAvailable => "not_available",
155            AbiError::InvalidPointer => "invalid_pointer",
156            AbiError::InvalidKernel => "invalid_kernel",
157            AbiError::MalformedPtx => "malformed_ptx",
158            AbiError::LaunchFailed => "launch_failed",
159            AbiError::QuotaExceeded => "quota_exceeded",
160            AbiError::Internal => "internal",
161            AbiError::InvalidDimensions => "invalid_dimensions",
162            AbiError::InvalidArgs => "invalid_args",
163            AbiError::KernelArgsUnsupported => "kernel_args_unsupported",
164            AbiError::InvalidHandle => "invalid_handle",
165        }
166    }
167}
168
169/// Maximum PTX module length we'll accept from a single `load_ptx` call.
170pub const MAX_PTX_BYTES: usize = 8 * 1024 * 1024;
171
172/// Maximum number of kernels a single instance may keep alive.
173pub const MAX_KERNELS_PER_INSTANCE: usize = 256;
174
175/// Maximum threads-per-block product (`block_x * block_y * block_z`).
176/// CUDA hardware cap on every device shipped since Kepler.
177pub const MAX_THREADS_PER_BLOCK: u32 = 1024;
178
179/// Maximum per-axis block dimension. The CUDA driver allows up to 1024 for
180/// `block_x` / `block_y` and 64 for `block_z`; we cap each axis at 1024 and
181/// rely on the [`MAX_THREADS_PER_BLOCK`] product check to enforce the z
182/// constraint indirectly (any z > 64 paired with non-trivial x or y will
183/// already exceed 1024 threads).
184pub const MAX_BLOCK_DIM: u32 = 1024;
185
186/// Maximum per-axis grid dimension. CUDA driver maximum for `grid_x` is
187/// `2^31 - 1`; `grid_y` / `grid_z` are `2^16 - 1`, but we keep the cap
188/// uniform at the larger value and let the driver enforce the per-axis
189/// distinction (`cuLaunchKernel` returns `CUDA_ERROR_INVALID_VALUE`).
190pub const MAX_GRID_DIM: u32 = i32::MAX as u32;
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    #[test]
197    fn abi_error_codes_stable() {
198        assert_eq!(AbiError::NotAvailable.code(), -1);
199        assert_eq!(AbiError::InvalidPointer.code(), -2);
200        assert_eq!(AbiError::InvalidKernel.code(), -3);
201        assert_eq!(AbiError::MalformedPtx.code(), -4);
202        assert_eq!(AbiError::LaunchFailed.code(), -5);
203        assert_eq!(AbiError::QuotaExceeded.code(), -6);
204        assert_eq!(AbiError::Internal.code(), -7);
205        assert_eq!(AbiError::InvalidDimensions.code(), -8);
206        assert_eq!(AbiError::InvalidArgs.code(), -9);
207        assert_eq!(AbiError::KernelArgsUnsupported.code(), -10);
208        assert_eq!(AbiError::InvalidHandle.code(), -11);
209    }
210
211    #[test]
212    fn abi_error_names_stable() {
213        assert_eq!(AbiError::NotAvailable.name(), "not_available");
214        assert_eq!(AbiError::MalformedPtx.name(), "malformed_ptx");
215        assert_eq!(AbiError::InvalidDimensions.name(), "invalid_dimensions");
216        assert_eq!(AbiError::InvalidArgs.name(), "invalid_args");
217        assert_eq!(
218            AbiError::KernelArgsUnsupported.name(),
219            "kernel_args_unsupported"
220        );
221        assert_eq!(AbiError::InvalidHandle.name(), "invalid_handle");
222    }
223
224    #[test]
225    fn function_names_carry_wasi_cuda_prefix() {
226        for name in [
227            FN_LOAD_PTX,
228            FN_LAUNCH,
229            FN_SYNC,
230            FN_LAST_ERROR_PTR,
231            FN_LAST_ERROR_LEN,
232            FN_LAST_ERROR_COPY,
233            FN_ALLOC,
234            FN_FREE,
235            FN_MEMCPY_H2D,
236            FN_MEMCPY_D2H,
237        ] {
238            assert!(name.starts_with("wasi_cuda_"));
239        }
240    }
241
242    #[test]
243    fn module_string_is_versioned() {
244        assert!(MODULE.contains("wasi:cuda"));
245        assert!(MODULE.ends_with("@0.2.0"));
246    }
247
248    /// Pin the host MODULE string against drift from `wit/wasi-cuda.wit`.
249    ///
250    /// The WIT file is the authoritative spec; the host's import-module
251    /// name has to carry the same `@x.y.z` segment so guests generated
252    /// from the WIT can link. Parse the version out of the WIT
253    /// `package wasi:cuda@x.y.z;` line and compare to the suffix of
254    /// [`MODULE`]. If somebody bumps one without the other, this test
255    /// trips before the linker error reaches downstream users.
256    #[test]
257    fn module_version_matches_wit_package_decl() {
258        const WIT: &str = include_str!("../wit/wasi-cuda.wit");
259        // Path is relative to this source file (`crates/tensor-wasm-wasi-gpu/src/abi.rs`):
260        //   ../  -> crates/tensor-wasm-wasi-gpu/, where the in-crate
261        //          `wit/wasi-cuda.wit` copy lives. Kept in-crate so the
262        //          published tarball is self-contained (`cargo publish`
263        //          rejects paths that escape the crate root).
264        let pkg_line = WIT
265            .lines()
266            .find(|l| l.trim_start().starts_with("package wasi:cuda@"))
267            .expect("wit/wasi-cuda.wit must declare `package wasi:cuda@x.y.z;`");
268        let version = pkg_line
269            .trim()
270            .trim_start_matches("package wasi:cuda@")
271            .trim_end_matches(';')
272            .trim();
273        assert!(
274            !version.is_empty(),
275            "could not parse a version out of: {pkg_line:?}"
276        );
277        let expected_suffix = format!("@{version}");
278        assert!(
279            MODULE.ends_with(&expected_suffix),
280            "MODULE ({MODULE:?}) drifted from wit/wasi-cuda.wit \
281             package version ({version:?}); update src/abi.rs::MODULE \
282             or the WIT file so they agree."
283        );
284    }
285
286    /// Guard against stray git merge-conflict markers in the in-crate WIT
287    /// contract.
288    ///
289    /// `wit/wasi-cuda.wit` ships inside the published crate tarball and is
290    /// consumed verbatim by external `wit-bindgen` users — any leftover
291    /// `<<<<<<<` / `=======` / `>>>>>>>` line will break those downstream
292    /// builds long before this crate's own host tests notice. The original
293    /// `module_version_matches_wit_package_decl` test only inspects the
294    /// `package` line, so a marker further down the file slips through.
295    ///
296    /// We use byte-pattern substrings rather than full marker strings (e.g.
297    /// `"<<<<<<< HEAD"`) so the test catches markers regardless of which
298    /// branch label git chose. The markers themselves are constructed via
299    /// `str::repeat` so this test file is safe to include in any future
300    /// merge — the literal source here never contains seven of any conflict
301    /// character in a row.
302    #[test]
303    fn wit_file_has_no_merge_conflict_markers() {
304        const WIT: &str = include_str!("../wit/wasi-cuda.wit");
305        let ours = "<".repeat(7);
306        let sep = "=".repeat(7);
307        let theirs = ">".repeat(7);
308        for (label, marker) in [
309            ("ours (<<<<<<<)", ours.as_str()),
310            ("separator (=======)", sep.as_str()),
311            ("theirs (>>>>>>>)", theirs.as_str()),
312        ] {
313            for (lineno, line) in WIT.lines().enumerate() {
314                assert!(
315                    !line.contains(marker),
316                    "wit/wasi-cuda.wit contains an unresolved git merge \
317                     conflict marker {label} on line {} — resolve it before \
318                     publishing or downstream `wit-bindgen` consumers will \
319                     fail to parse the contract. Offending line: {line:?}",
320                    lineno + 1,
321                );
322            }
323        }
324    }
325
326    /// Drift guard: the in-crate authoritative `wit/wasi-cuda.wit` and the
327    /// workspace-root mirror `wit/wasi-cuda.wit` must carry a byte-identical
328    /// `interface host { ... }` body.
329    ///
330    /// Two copies of the cuda WIT exist:
331    ///   * `crates/tensor-wasm-wasi-gpu/wit/wasi-cuda.wit` — authoritative,
332    ///     `include_str!`'d by the tests above and shipped in the published
333    ///     crate tarball.
334    ///   * `<workspace-root>/wit/wasi-cuda.wit` — mirror for tooling that
335    ///     consumes WIT from the workspace root.
336    ///
337    /// The file *headers* legitimately differ (the in-crate copy documents
338    /// both `wasi:cuda` and `wasi:tensor`; the mirror is cuda-only), so we
339    /// compare only the `interface host` body — the part guests actually
340    /// bind against. If the mirror drifts (a signature/error-code edit
341    /// applied to one copy but not the other) this trips before downstream
342    /// `wit-bindgen` consumers see two different contracts.
343    ///
344    /// This is a *runtime* test (reads both files via `CARGO_MANIFEST_DIR`)
345    /// rather than a compile-time `include_str!("../../../wit/...")`: the
346    /// root path escapes the crate root, which `cargo publish` rejects when
347    /// packaging the tarball.
348    #[test]
349    fn cuda_wit_mirror_interface_body_matches() {
350        use std::path::Path;
351
352        /// Extract the `interface host { ... }` block (inclusive of the
353        /// opening `interface host {` line through its matching `}`), with
354        /// leading/trailing whitespace trimmed, so header differences and
355        /// surrounding blank lines do not register as drift.
356        fn interface_host_body(src: &str) -> String {
357            let start = src
358                .find("interface host {")
359                .expect("WIT must declare `interface host {`");
360            let rest = &src[start..];
361            // Walk brace depth to find the matching close brace.
362            let mut depth = 0usize;
363            let mut end = None;
364            for (idx, ch) in rest.char_indices() {
365                match ch {
366                    '{' => depth += 1,
367                    '}' => {
368                        depth -= 1;
369                        if depth == 0 {
370                            end = Some(idx + 1);
371                            break;
372                        }
373                    }
374                    _ => {}
375                }
376            }
377            let end = end.expect("interface host block must be brace-balanced");
378            rest[..end].trim().to_string()
379        }
380
381        let manifest = env!("CARGO_MANIFEST_DIR");
382        let in_crate_path = Path::new(manifest).join("wit").join("wasi-cuda.wit");
383        // CARGO_MANIFEST_DIR is `<root>/crates/tensor-wasm-wasi-gpu`; the
384        // workspace root is two levels up.
385        let root_path = Path::new(manifest)
386            .join("..")
387            .join("..")
388            .join("wit")
389            .join("wasi-cuda.wit");
390
391        let in_crate = std::fs::read_to_string(&in_crate_path)
392            .unwrap_or_else(|e| panic!("read {in_crate_path:?}: {e}"));
393        let root = std::fs::read_to_string(&root_path)
394            .unwrap_or_else(|e| panic!("read {root_path:?}: {e}"));
395
396        assert_eq!(
397            interface_host_body(&in_crate),
398            interface_host_body(&root),
399            "wasi-cuda.wit `interface host` body drifted between the in-crate \
400             authoritative copy ({in_crate_path:?}) and the workspace-root \
401             mirror ({root_path:?}); update both so guests bind one contract."
402        );
403    }
404
405    #[test]
406    fn dimension_caps_are_plausible() {
407        // Defensive sanity checks — bumping these accidentally would let
408        // launch requests through that the driver will then reject in a
409        // less actionable way.
410        assert_eq!(MAX_THREADS_PER_BLOCK, 1024);
411        assert_eq!(MAX_BLOCK_DIM, 1024);
412        assert_eq!(MAX_GRID_DIM, i32::MAX as u32);
413    }
414}