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}