Skip to main content

tensor_wasm_wasi_gpu/
registry.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Craton Software Company
3
4//! [`KernelRegistry`] — instance-scoped store of compiled PTX kernels.
5//!
6//! Every Wasm instance gets its own registry. Kernel IDs are scoped to the
7//! owning [`InstanceId`]; the host functions
8//! refuse to launch a kernel using an ID that belongs to a different
9//! instance (`AbiError::InvalidKernel`).
10//!
11//! ## Memory safety: why `Arc<cust::module::Module>`
12//!
13//! The compiled module is held behind an `Arc` so that `lookup` can hand back
14//! a strong reference whose lifetime is independent of the underlying
15//! `dashmap` entry. A previous design returned a raw pointer materialised
16//! from a transient `dashmap::Ref` — that ref was dropped before the launch
17//! site dereferenced the pointer, producing a use-after-free under
18//! concurrent `register` + `remove`. With `Arc`, `lookup` clones the
19//! pointer-bump (atomic +1) and the launch path holds the strong reference
20//! for the full duration of `cuLaunchKernel` and the subsequent
21//! `stream.synchronize()`. Concurrent `remove` only decrements the strong
22//! count; the module is dropped (and CUDA's `cuModuleUnload` runs) once the
23//! last launch has finished.
24
25use std::sync::atomic::{AtomicU64, Ordering};
26#[cfg(feature = "cuda")]
27use std::sync::Arc;
28
29use dashmap::DashMap;
30use tensor_wasm_core::types::{InstanceId, KernelId};
31
32use crate::abi::{AbiError, MAX_KERNELS_PER_INSTANCE, MAX_PTX_BYTES};
33
34/// Soft cap on aggregate retained PTX bytes per instance (sum of
35/// `ptx_bytes_len` across live entries). Set to 64x the per-call cap so a
36/// single malicious instance cannot pin 2 GiB of host memory in the
37/// registry before tripping `MAX_KERNELS_PER_INSTANCE`.
38pub const MAX_TOTAL_PTX_BYTES: usize = 64 * MAX_PTX_BYTES;
39
40/// Metadata about a single compiled kernel. The actual compiled module is
41/// held opaquely behind a feature gate — on non-CUDA builds the field is
42/// absent so the registry can still be exercised by tests.
43#[derive(Debug)]
44pub struct KernelEntry {
45    /// Owning instance (used to authorise `launch` calls).
46    pub owner: InstanceId,
47    /// Entry-point symbol name inside the PTX module.
48    pub entry: String,
49    /// Size of the PTX source that produced this kernel (bytes).
50    pub ptx_bytes_len: usize,
51    /// CUDA-side handle; only meaningful when the `cuda` feature is enabled.
52    ///
53    /// Held in an `Arc` so the launch path can take a strong reference
54    /// independent of the `dashmap` entry's lifetime — preventing a UAF
55    /// under concurrent `register`/`remove`.
56    #[cfg(feature = "cuda")]
57    pub module: Option<Arc<cust::module::Module>>,
58}
59
60/// Instance-scoped kernel registry.
61pub struct KernelRegistry {
62    next_id: AtomicU64,
63    entries: DashMap<KernelId, KernelEntry>,
64    /// Sum of `ptx_bytes_len` across live entries. Tracked separately so
65    /// `register` can reject above [`MAX_TOTAL_PTX_BYTES`] without scanning
66    /// the whole map.
67    total_ptx_bytes: AtomicU64,
68}
69
70impl Default for KernelRegistry {
71    fn default() -> Self {
72        Self::new()
73    }
74}
75
76/// Cheap, cloneable handle to a kernel entry's stable fields.
77///
78/// Returned by [`KernelRegistry::lookup`]. The handle owns an `Arc` to the
79/// compiled module on CUDA builds, so callers may drop the originating
80/// `dashmap` ref before launching.
81#[derive(Clone, Debug)]
82pub struct KernelHandle {
83    /// Owning instance.
84    pub owner: InstanceId,
85    /// Entry-point symbol name.
86    pub entry: String,
87    /// PTX source size in bytes.
88    pub ptx_bytes_len: usize,
89    /// Strong reference to the compiled module on CUDA builds.
90    #[cfg(feature = "cuda")]
91    pub module: Option<Arc<cust::module::Module>>,
92}
93
94/// Pick a random seed for [`KernelRegistry::next_id`] so that kernel ids are
95/// not trivially guessable (wasi-gpu 1.1).
96///
97/// Cross-tenant id forgery is already prevented by the owner-`InstanceId`
98/// check in [`KernelRegistry::lookup`], so this seed is defence-in-depth: it
99/// stops a guest from enumerating its own id space without bookkeeping (and,
100/// by extension, fingerprinting which ids exist by observing the
101/// `InvalidKernel`-vs-`InvalidDimensions` discrimination on `launch`).
102///
103/// The seed is sampled from the OS RNG. We reserve the high bit so callers
104/// reading the id as a signed `i64` from the WIT interface stay in the
105/// positive range, and we mask off the bottom 16 bits so the first few
106/// thousand registrations don't land suspiciously close to 0.
107fn random_kernel_id_seed() -> u64 {
108    use std::collections::hash_map::RandomState;
109    use std::hash::{BuildHasher, Hasher};
110    // `RandomState` seeds itself from the OS RNG on construction. Using two
111    // hashers gives us 64 bits of entropy without taking an explicit
112    // dependency on `rand` (which the workspace does not currently pull
113    // into this crate). The construction is `std`-only.
114    let mut h = RandomState::new().build_hasher();
115    h.write_u64(0xa5a5_a5a5_a5a5_a5a5);
116    let raw = h.finish();
117    // Clear the high bit (keep ids in the positive `i64` range) and the
118    // low 16 bits (avoid an obviously-small starting value). The +1 floor
119    // keeps the invariant `next_id != 0` so callers can reserve 0 as a
120    // sentinel if they wish.
121    (raw & 0x7fff_ffff_ffff_0000) | 1
122}
123
124impl KernelRegistry {
125    /// Construct an empty registry.
126    pub fn new() -> Self {
127        Self {
128            // Seeded with `random_kernel_id_seed` so the id space is
129            // unguessable (wasi-gpu 1.1).
130            next_id: AtomicU64::new(random_kernel_id_seed()),
131            entries: DashMap::new(),
132            total_ptx_bytes: AtomicU64::new(0),
133        }
134    }
135
136    /// Register a new kernel and return its assigned [`KernelId`].
137    ///
138    /// Returns [`AbiError::QuotaExceeded`] if either the per-instance kernel
139    /// count cap ([`MAX_KERNELS_PER_INSTANCE`]) or the aggregate PTX-bytes
140    /// cap ([`MAX_TOTAL_PTX_BYTES`]) would be exceeded.
141    pub fn register(&self, entry: KernelEntry) -> Result<KernelId, AbiError> {
142        if self.entries.len() >= MAX_KERNELS_PER_INSTANCE {
143            return Err(AbiError::QuotaExceeded);
144        }
145        // Aggregate-bytes check: prevents an instance from pinning gigabytes
146        // of registry memory with many large PTX modules below the per-call
147        // cap.
148        let add = entry.ptx_bytes_len as u64;
149        // Use a compare-and-swap loop so the check + add is atomic against
150        // concurrent registrations.
151        let mut current = self.total_ptx_bytes.load(Ordering::Acquire);
152        loop {
153            let next = current.saturating_add(add);
154            if next > MAX_TOTAL_PTX_BYTES as u64 {
155                return Err(AbiError::QuotaExceeded);
156            }
157            match self.total_ptx_bytes.compare_exchange_weak(
158                current,
159                next,
160                Ordering::AcqRel,
161                Ordering::Acquire,
162            ) {
163                Ok(_) => break,
164                Err(observed) => current = observed,
165            }
166        }
167        let id = KernelId(self.next_id.fetch_add(1, Ordering::Relaxed));
168        self.entries.insert(id, entry);
169        Ok(id)
170    }
171
172    /// Look up a kernel by id and return an independent handle.
173    ///
174    /// Returns `Err(AbiError::InvalidKernel)` if the id is unknown or belongs
175    /// to a different instance. The returned [`KernelHandle`] holds an
176    /// `Arc<Module>` on CUDA builds, so callers may drop any borrowed
177    /// `dashmap` entry before performing the launch — eliminating the UAF
178    /// that the previous `dashmap::Ref`-derived raw-pointer scheme had.
179    pub fn lookup(&self, id: KernelId, owner: InstanceId) -> Result<KernelHandle, AbiError> {
180        let r = self.entries.get(&id).ok_or(AbiError::InvalidKernel)?;
181        if r.owner != owner {
182            return Err(AbiError::InvalidKernel);
183        }
184        Ok(KernelHandle {
185            owner: r.owner,
186            entry: r.entry.clone(),
187            ptx_bytes_len: r.ptx_bytes_len,
188            #[cfg(feature = "cuda")]
189            module: r.module.clone(),
190        })
191    }
192
193    /// Remove a kernel from the registry (caller releases its handle).
194    ///
195    /// On CUDA builds the underlying `cust::module::Module` is held inside
196    /// an `Arc`; dropping the registry entry only decrements the strong
197    /// count. If a concurrent `launch` is still holding a clone of the
198    /// `Arc`, the module is not actually unloaded until that launch returns
199    /// — eliminating the UAF window that existed when the registry owned
200    /// the module by value.
201    pub fn remove(&self, id: KernelId) -> Option<KernelEntry> {
202        let (_, entry) = self.entries.remove(&id)?;
203        // Decrement aggregate-bytes counter. Saturating_sub guards against
204        // underflow if ever the counter drifts; the entry's own
205        // `ptx_bytes_len` is the source of truth.
206        let _ = self
207            .total_ptx_bytes
208            .fetch_update(Ordering::AcqRel, Ordering::Acquire, |cur| {
209                Some(cur.saturating_sub(entry.ptx_bytes_len as u64))
210            });
211        Some(entry)
212    }
213
214    /// Number of currently-registered kernels.
215    pub fn len(&self) -> usize {
216        self.entries.len()
217    }
218
219    /// True if there are no registered kernels.
220    pub fn is_empty(&self) -> bool {
221        self.entries.is_empty()
222    }
223
224    /// Aggregate PTX bytes currently retained by the registry. Visible for
225    /// metrics and tests.
226    pub fn total_ptx_bytes(&self) -> u64 {
227        self.total_ptx_bytes.load(Ordering::Acquire)
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    fn make_entry(owner: InstanceId, entry: &str) -> KernelEntry {
236        KernelEntry {
237            owner,
238            entry: entry.into(),
239            ptx_bytes_len: 1024,
240            #[cfg(feature = "cuda")]
241            module: None,
242        }
243    }
244
245    fn make_entry_sized(owner: InstanceId, entry: &str, bytes: usize) -> KernelEntry {
246        KernelEntry {
247            owner,
248            entry: entry.into(),
249            ptx_bytes_len: bytes,
250            #[cfg(feature = "cuda")]
251            module: None,
252        }
253    }
254
255    #[test]
256    fn register_then_lookup() {
257        let reg = KernelRegistry::new();
258        let id = reg
259            .register(make_entry(InstanceId(1), "vector_add"))
260            .unwrap();
261        let entry = reg.lookup(id, InstanceId(1)).unwrap();
262        assert_eq!(entry.owner, InstanceId(1));
263        assert_eq!(entry.entry, "vector_add");
264    }
265
266    #[test]
267    fn lookup_wrong_owner_rejected() {
268        let reg = KernelRegistry::new();
269        let id = reg
270            .register(make_entry(InstanceId(1), "vector_add"))
271            .unwrap();
272        let err = reg.lookup(id, InstanceId(2)).unwrap_err();
273        assert_eq!(err, AbiError::InvalidKernel);
274    }
275
276    #[test]
277    fn lookup_unknown_rejected() {
278        let reg = KernelRegistry::new();
279        let err = reg.lookup(KernelId(42), InstanceId(1)).unwrap_err();
280        assert_eq!(err, AbiError::InvalidKernel);
281    }
282
283    #[test]
284    fn remove_drops_entry() {
285        let reg = KernelRegistry::new();
286        let id = reg
287            .register(make_entry(InstanceId(1), "vector_add"))
288            .unwrap();
289        assert!(reg.remove(id).is_some());
290        assert!(reg.lookup(id, InstanceId(1)).is_err());
291    }
292
293    #[test]
294    fn ids_are_unique_and_increasing() {
295        let reg = KernelRegistry::new();
296        let a = reg.register(make_entry(InstanceId(1), "k1")).unwrap();
297        let b = reg.register(make_entry(InstanceId(1), "k2")).unwrap();
298        let c = reg.register(make_entry(InstanceId(1), "k3")).unwrap();
299        assert_ne!(a, b);
300        assert_ne!(b, c);
301        assert_eq!(a.0 + 1, b.0);
302        assert_eq!(b.0 + 1, c.0);
303    }
304
305    #[test]
306    fn len_tracks_entries() {
307        let reg = KernelRegistry::new();
308        assert!(reg.is_empty());
309        let id = reg.register(make_entry(InstanceId(1), "k")).unwrap();
310        assert_eq!(reg.len(), 1);
311        reg.remove(id);
312        assert!(reg.is_empty());
313    }
314
315    #[test]
316    fn aggregate_byte_cap_enforced() {
317        let reg = KernelRegistry::new();
318        // Try to register enough entries to exceed MAX_TOTAL_PTX_BYTES.
319        // We pick a sub-MAX_PTX_BYTES per-entry size so the per-call cap is
320        // not the gating constraint here.
321        let per = MAX_PTX_BYTES; // 8 MiB
322        let cap_count = MAX_TOTAL_PTX_BYTES / per; // 64
323        for i in 0..cap_count {
324            reg.register(make_entry_sized(InstanceId(1), &format!("k{i}"), per))
325                .expect("under aggregate cap");
326        }
327        // The 65th registration at the per-entry max trips the aggregate cap.
328        let err = reg
329            .register(make_entry_sized(InstanceId(1), "k_over", per))
330            .unwrap_err();
331        assert_eq!(err, AbiError::QuotaExceeded);
332    }
333
334    #[test]
335    fn aggregate_bytes_counter_decreases_on_remove() {
336        let reg = KernelRegistry::new();
337        let id = reg
338            .register(make_entry_sized(InstanceId(1), "k", 4096))
339            .unwrap();
340        assert_eq!(reg.total_ptx_bytes(), 4096);
341        reg.remove(id);
342        assert_eq!(reg.total_ptx_bytes(), 0);
343    }
344
345    #[test]
346    fn lookup_returns_independent_handle() {
347        // The handle is cheap to clone and outlives the entry's dashmap ref:
348        // construct it, drop nothing explicit, then immediately remove the
349        // entry. Handle fields stay valid (no UAF) because they were copied.
350        let reg = KernelRegistry::new();
351        let id = reg.register(make_entry(InstanceId(1), "v")).unwrap();
352        let handle = reg.lookup(id, InstanceId(1)).unwrap();
353        assert!(reg.remove(id).is_some());
354        assert_eq!(handle.entry, "v");
355        assert_eq!(handle.owner, InstanceId(1));
356    }
357}