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}