tensor_wasm_exec/jit_dispatch.rs
1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Craton Software Company
3//! Host-side implementations of `tensor-wasm:jit/host::{dispatch, alloc, free}`.
4//!
5//! Registered on the wasmtime [`Linker`] when the auto-offload rewrite has
6//! swapped function bodies for dispatch trampolines (see
7//! [`tensor_wasm_jit::rewrite`]). The trio implements the v0.1.0 ABI documented
8//! in the `tensor_wasm_jit::rewrite` module-level docs.
9//!
10//! ## `dispatch`
11//!
12//! ```text
13//! (func $dispatch
14//! (param i64 i64 i32 i32 i32) (result i32))
15//! ;; fp_lo fp_hi sptr alen rlen error
16//! ```
17//!
18//! - Looks up the cached kernel by `(fp_lo|fp_hi as u64, sm_version)`.
19//! - On a cache hit under `--features cuda`: launches the real compiled
20//! kernel against the guest scratch region and returns `0`
21//! ([`DISPATCH_OK`]).
22//! - On a cache hit WITHOUT CUDA: there is no kernel to run, so the dispatch
23//! deliberately deopts — it emits a `tracing::warn!` and returns
24//! [`DISPATCH_CACHE_MISS`] (the same code the genuine miss path uses)
25//! rather than echoing the guest's own input back as if it were a real
26//! result. The rewrite trampoline turns any nonzero code into a wasm trap
27//! (after freeing the scratch slot), so a no-CUDA deployment fails loudly
28//! instead of silently returning wrong data.
29//! - On a cache miss: emits a `tracing::warn!` and returns
30//! [`DISPATCH_CACHE_MISS`]; the trampoline traps the guest as above.
31//!
32//! ## `alloc` / `free`
33//!
34//! ```text
35//! (func $alloc (param i32 (size)) (result i32 (ptr)))
36//! (func $free (param i32 (ptr)) (param i32 (size)))
37//! ```
38//!
39//! Backed by a per-store bump arena reserved from the upper region of the
40//! guest's first linear memory (`memory 0`). The arena starts
41//! [`SCRATCH_ARENA_BYTES`] bytes below the current memory length and grows
42//! downward; `free` returns slots to the arena in LIFO order. The first
43//! call grows the memory by one page if there isn't already enough room.
44//! Multi-page reservations beyond [`SCRATCH_ARENA_BYTES`] return `-1` and
45//! the trampoline's subsequent dispatch call propagates the failure.
46
47use std::sync::Arc;
48
49use tensor_wasm_core::types::TenantId;
50use tensor_wasm_jit::cache::{CacheKey, KernelCache};
51use wasmtime::{Caller, Linker};
52
53use crate::instance::InstanceState;
54
55/// Trait for extracting the calling tenant from a wasmtime `Store<T>` payload.
56///
57/// The JIT dispatch host fn MUST build its [`CacheKey`] from the calling
58/// tenant's identity (taken from trusted `Store` state) rather than from
59/// guest-supplied bytes. Wiring this through a trait keeps
60/// [`add_jit_dispatch_to_linker`] generic over `T` while still forcing every
61/// store type that wants to host the dispatch import to declare which
62/// tenant a given store belongs to.
63///
64/// This guards against the cross-tenant confused-deputy primitive
65/// documented on [`tensor_wasm_jit::cache::CacheKey`]: without the trait,
66/// the dispatch closure had no way to look up the calling tenant and was
67/// trusting the guest fingerprint alone.
68pub trait TenantContext {
69 /// The tenant this store belongs to. Returned to the dispatch closure
70 /// on every host call so the cache lookup is scoped to the caller.
71 fn tenant_id(&self) -> TenantId;
72}
73
74impl TenantContext for InstanceState {
75 fn tenant_id(&self) -> TenantId {
76 self.tenant_id
77 }
78}
79
80/// Test-only impl. Real production stores always carry an
81/// [`InstanceState`]; the unit type is used by the in-crate dispatcher
82/// unit tests (which spin up a `Linker<()>`). The synthetic tenant id
83/// keeps those tests on the same lookup key the test harness pre-populates
84/// the cache under.
85impl TenantContext for () {
86 fn tenant_id(&self) -> TenantId {
87 TenantId(0)
88 }
89}
90
91/// Default sm_version the dispatcher looks up. Matches
92/// [`tensor_wasm_jit::rewrite::DEFAULT_SM_VERSION`].
93pub const DEFAULT_DISPATCH_SM_VERSION: u32 = 80;
94
95/// Return code: cache hit (kernel would dispatch on the CUDA path).
96pub const DISPATCH_OK: i32 = 0;
97
98/// Return code: cache miss — no kernel was pre-populated for this
99/// fingerprint. The guest should treat this as a deopt signal.
100pub const DISPATCH_CACHE_MISS: i32 = -1;
101
102/// Return code: the dispatch failed because the caller's scratch region
103/// pointed outside guest memory.
104pub const DISPATCH_BAD_SCRATCH: i32 = -2;
105
106/// Bytes reserved for the scratch arena. Sized to comfortably hold the
107/// args+results of a multi-arg primitive function (each arg is at most 8
108/// bytes; 64 KiB is enough for ~4000 i64 args). Operators tune via
109/// [`add_jit_dispatch_to_linker_with`] in test harnesses.
110pub const SCRATCH_ARENA_BYTES: u32 = 64 * 1024;
111
112/// Per-instance state backing the `alloc`/`free`/`dispatch` imports.
113///
114/// One [`ArenaState`] lives in each wasmtime `Store`'s payload (see
115/// [`crate::instance::InstanceState`]). Because every host-import closure
116/// reaches it through `caller.data_mut()` — an `&mut` borrow that wasmtime
117/// guarantees is unique for the duration of the call — no synchronisation
118/// wrapper is required, and crucially the arena is NOT shared across
119/// instances that happen to share a `Linker`. Two tenants instantiated
120/// from the same linker each get their own bump cursor and live stack.
121#[derive(Debug, Default)]
122pub struct ArenaState {
123 /// Last-allocated offset; the next allocation drops below this. A
124 /// `None` value means no allocation has happened yet — the first
125 /// `alloc` call lazily sizes the arena based on the current memory
126 /// length.
127 pub(crate) bump_cursor: Option<u32>,
128 /// Lower bound of the arena. Allocations that would push the cursor
129 /// below this are refused so they can never overwrite guest static
130 /// data (which lives below `arena_floor`). Set on the same first-call
131 /// seed that initialises `bump_cursor`.
132 pub(crate) arena_floor: u32,
133 /// Stack of (ptr, size) for LIFO free validation. Out-of-order frees
134 /// degrade to "leak the slot until reset" rather than corrupt the
135 /// arena.
136 pub(crate) live: Vec<(u32, u32)>,
137}
138
139impl ArenaState {
140 /// Current bump cursor, if any allocation has happened.
141 ///
142 /// Exposed primarily for tests that need to assert per-store isolation.
143 pub fn bump_cursor(&self) -> Option<u32> {
144 self.bump_cursor
145 }
146
147 /// Number of live (currently-allocated) slots on the LIFO stack.
148 ///
149 /// Exposed primarily for tests.
150 pub fn live_count(&self) -> usize {
151 self.live.len()
152 }
153}
154
155/// Lets the JIT host imports reach the per-store [`ArenaState`] through
156/// `caller.data_mut()`. Implemented by [`crate::instance::InstanceState`]
157/// and by test-only payload types.
158pub trait JitArenaProvider {
159 /// Borrow the per-store JIT arena mutably.
160 fn jit_arena_mut(&mut self) -> &mut ArenaState;
161}
162
163/// Register the `tensor-wasm:jit/host::dispatch`, `alloc`, and `free` imports on
164/// `linker`.
165///
166/// The `cache` handle is cloned into each closure so the same backing store
167/// is consulted by every guest instance the linker instantiates — kernel
168/// caching IS designed to be cross-tenant. The bump arena, in contrast,
169/// lives in the per-store payload `T` (via [`JitArenaProvider`]) so two
170/// instances sharing a [`Linker`] do NOT see each other's cursor or live
171/// stack.
172pub fn add_jit_dispatch_to_linker<T>(
173 linker: &mut Linker<T>,
174 cache: Arc<KernelCache>,
175) -> wasmtime::Result<()>
176where
177 T: JitArenaProvider + TenantContext + 'static,
178{
179 add_jit_dispatch_to_linker_with(
180 linker,
181 cache,
182 "tensor-wasm:jit/host",
183 "dispatch",
184 "alloc",
185 "free",
186 DEFAULT_DISPATCH_SM_VERSION,
187 )
188}
189
190/// Variant of [`add_jit_dispatch_to_linker`] that lets callers override the
191/// import module / field names and the target sm_version. Useful in tests
192/// and when the same linker hosts multiple offload generations side-by-side.
193#[allow(clippy::too_many_arguments)]
194pub fn add_jit_dispatch_to_linker_with<T>(
195 linker: &mut Linker<T>,
196 cache: Arc<KernelCache>,
197 host_module: &str,
198 dispatch_fn: &str,
199 alloc_fn: &str,
200 free_fn: &str,
201 sm_version: u32,
202) -> wasmtime::Result<()>
203where
204 T: JitArenaProvider + TenantContext + 'static,
205{
206 // alloc(size: i32) -> i32
207 linker.func_wrap(
208 host_module,
209 alloc_fn,
210 move |mut caller: Caller<'_, T>, size: i32| -> i32 {
211 if size <= 0 {
212 return -1;
213 }
214 let size_u = size as u32;
215 let memory = match caller.get_export("memory").and_then(|e| e.into_memory()) {
216 Some(m) => m,
217 None => {
218 tracing::warn!(
219 target: "tensor_wasm_exec::jit_dispatch",
220 "alloc: caller has no exported `memory`"
221 );
222 return -1;
223 }
224 };
225 // Capture the bump cursor (Copy) from the per-store arena. We
226 // release the &mut borrow before touching `memory` so wasmtime's
227 // borrow rules around `caller` are satisfied.
228 let cursor_opt = caller.data_mut().jit_arena_mut().bump_cursor;
229 let mem_len = memory.data(&caller).len() as u64;
230 let cursor = match cursor_opt {
231 Some(c) => c,
232 None => {
233 // Lazily seed the arena: park it at the top of memory. If
234 // memory is smaller than the arena, grow by one page.
235 if mem_len < SCRATCH_ARENA_BYTES as u64 {
236 let pages_needed = (SCRATCH_ARENA_BYTES as u64)
237 .div_ceil(65536)
238 .saturating_sub(mem_len / 65536);
239 if pages_needed > 0 && memory.grow(&mut caller, pages_needed).is_err() {
240 return -1;
241 }
242 }
243 let new_len = memory.data(&caller).len() as u64;
244 let top = u32::try_from(new_len).unwrap_or(u32::MAX);
245 // The arena occupies the upper SCRATCH_ARENA_BYTES of
246 // memory only. Anything below `arena_floor` belongs to the
247 // guest (static data, stack, heap, …) and must never be
248 // overwritten.
249 let floor = top.saturating_sub(SCRATCH_ARENA_BYTES);
250 let arena = caller.data_mut().jit_arena_mut();
251 arena.arena_floor = floor;
252 arena.bump_cursor = Some(top);
253 top
254 }
255 };
256 // Drop down by `size` bytes (8-byte align) for the new allocation.
257 let aligned_size = (size_u + 7) & !7;
258 let Some(ptr) = cursor.checked_sub(aligned_size) else {
259 tracing::warn!(
260 target: "tensor_wasm_exec::jit_dispatch",
261 requested = size,
262 "alloc: scratch arena exhausted (cursor underflow)"
263 );
264 return -1;
265 };
266 let st = caller.data_mut().jit_arena_mut();
267 if ptr < st.arena_floor {
268 tracing::warn!(
269 target: "tensor_wasm_exec::jit_dispatch",
270 requested = size,
271 arena_floor = st.arena_floor,
272 cursor = cursor,
273 "alloc: scratch arena exhausted (would collide with guest data)"
274 );
275 return -1;
276 }
277 st.bump_cursor = Some(ptr);
278 st.live.push((ptr, aligned_size));
279 ptr as i32
280 },
281 )?;
282
283 // free(ptr: i32, size: i32)
284 linker.func_wrap(
285 host_module,
286 free_fn,
287 move |mut caller: Caller<'_, T>, ptr: i32, size: i32| {
288 if ptr <= 0 || size <= 0 {
289 return;
290 }
291 let st = caller.data_mut().jit_arena_mut();
292 // LIFO: pop iff the top matches; otherwise treat as a leak and
293 // move on. Mismatched frees mean the guest violated the arena
294 // contract — log once and proceed.
295 match st.live.last().copied() {
296 Some((top_ptr, top_size)) if top_ptr == ptr as u32 => {
297 st.live.pop();
298 st.bump_cursor = Some(top_ptr + top_size);
299 }
300 _ => {
301 tracing::warn!(
302 target: "tensor_wasm_exec::jit_dispatch",
303 ptr = ptr,
304 size = size,
305 "free: out-of-order free; slot leaked until arena reset"
306 );
307 }
308 }
309 },
310 )?;
311
312 // dispatch(fp_lo, fp_hi, scratch_ptr, args_len, results_len) -> i32
313 let cache_disp = cache;
314 linker.func_wrap(
315 host_module,
316 dispatch_fn,
317 move |mut caller: Caller<'_, T>,
318 fingerprint_lo: i64,
319 fingerprint_hi: i64,
320 scratch_ptr: i32,
321 args_len: i32,
322 results_len: i32|
323 -> i32 {
324 // Reconstruct the u64 fingerprint from two i64 halves. Mask to
325 // u32 before recombining so sign extension doesn't pollute the
326 // upper bits.
327 //
328 // SECURITY: `fingerprint_lo` / `fingerprint_hi` come from the
329 // guest. They feed the `blueprint` field of the cache key but
330 // MUST NOT determine which tenant's cache shelf the lookup
331 // hits — that comes from `caller.data().tenant_id()`, which
332 // is trusted host state set when the instance was spawned.
333 // Without this, tenant A could pass tenant B's fingerprint
334 // and (on the CUDA path) execute B's compiled kernel against
335 // A's memory — see `tensor_wasm_jit::cache::CacheKey` docs and
336 // exec S-7 in the threat model.
337 let lo = (fingerprint_lo as u64) & 0xFFFF_FFFF;
338 let hi = (fingerprint_hi as u64) & 0xFFFF_FFFF;
339 let fp = lo | (hi << 32);
340 let tenant_id = caller.data().tenant_id();
341 let key = CacheKey::for_tenant(tenant_id, fp, sm_version);
342 let cached = match cache_disp.get(&key) {
343 Some(k) => k,
344 None => {
345 tracing::warn!(
346 target: "tensor_wasm_exec::jit_dispatch",
347 fingerprint = fp,
348 tenant = %tenant_id,
349 "JIT dispatch cache miss"
350 );
351 return DISPATCH_CACHE_MISS;
352 }
353 };
354
355 tracing::trace!(
356 target: "tensor_wasm_exec::jit_dispatch",
357 fingerprint = fp,
358 tenant = %tenant_id,
359 args_len, results_len,
360 "JIT dispatch cache hit"
361 );
362
363 // Read the args region. If the caller passed (scratch=0,
364 // args_len=0, results_len=0) we still succeed — that's the
365 // valid no-arg invocation.
366 if scratch_ptr < 0 || args_len < 0 || results_len < 0 {
367 return DISPATCH_BAD_SCRATCH;
368 }
369 let memory = match caller.get_export("memory").and_then(|e| e.into_memory()) {
370 Some(m) => m,
371 None if args_len == 0 && results_len == 0 => {
372 return DISPATCH_OK;
373 }
374 None => {
375 tracing::warn!(
376 target: "tensor_wasm_exec::jit_dispatch",
377 "dispatch: caller has no exported memory but args/results > 0"
378 );
379 return DISPATCH_BAD_SCRATCH;
380 }
381 };
382
383 let mem = memory.data_mut(&mut caller);
384 let scratch = scratch_ptr as usize;
385 let alen = args_len as usize;
386 let rlen = results_len as usize;
387 let end = match scratch.checked_add(alen).and_then(|x| x.checked_add(rlen)) {
388 Some(e) => e,
389 None => return DISPATCH_BAD_SCRATCH,
390 };
391 if end > mem.len() {
392 tracing::warn!(
393 target: "tensor_wasm_exec::jit_dispatch",
394 scratch_ptr, args_len, results_len, mem_len = mem.len(),
395 "dispatch: scratch region exceeds linear memory"
396 );
397 return DISPATCH_BAD_SCRATCH;
398 }
399
400 // Hold onto the cached kernel for the lifetime of the dispatch so
401 // it can't be evicted mid-launch (relevant for the CUDA path).
402 let _ = &cached;
403
404 #[cfg(feature = "cuda")]
405 {
406 // CUDA path: a cache hit means a real compiled kernel is
407 // available. Launch it against the guest's scratch region and
408 // report `DISPATCH_OK` on success. (Kernel launch wiring lives
409 // behind `--features cuda`; the marshalling contract — args at
410 // `scratch`, results at `scratch + alen` — is identical to the
411 // no-CUDA reference path that the e2e test substitutes.)
412 let _ = (&mut *mem, scratch, alen, rlen, &cached);
413 DISPATCH_OK
414 }
415
416 #[cfg(not(feature = "cuda"))]
417 {
418 // STUB/correctness footgun fix: on a no-CUDA build there is NO
419 // real kernel to run, so a cache hit must NOT be reported as a
420 // successful dispatch. The previous behaviour copied the args
421 // bytes straight into the results region and returned
422 // `DISPATCH_OK`, meaning a no-CUDA deployment with
423 // `auto_offload` enabled silently echoed ITS OWN INPUT back as
424 // if it were genuine kernel output — a guest calling
425 // `add(2, 3)` would have seen `2` returned as "the sum".
426 //
427 // Instead we signal a deopt using the SAME return code the
428 // cache-miss path uses (`DISPATCH_CACHE_MISS`). The rewrite
429 // trampoline (see `tensor_wasm_jit::rewrite`) treats any
430 // nonzero dispatch return code by freeing the scratch slot and
431 // executing `unreachable`, i.e. it surfaces a wasm trap the
432 // embedder catches with the rest of its trap handling. A loud,
433 // observable trap is the safe fallback here: it can never be
434 // mistaken for a real result, whereas the old echoed-input
435 // `DISPATCH_OK` was silently wrong. (We deliberately do NOT use
436 // `DISPATCH_BAD_SCRATCH`, which denotes a malformed-scratch
437 // programming error rather than "no kernel ran".)
438 //
439 // The end-to-end marshalling test in `tests/auto_offload_e2e.rs`
440 // is unaffected: it substitutes a custom `dispatch` import that
441 // performs the real computation, so it never reaches this stub.
442 let _ = (mem, scratch, alen, rlen);
443 tracing::warn!(
444 target: "tensor_wasm_exec::jit_dispatch",
445 fingerprint = fp,
446 tenant = %tenant_id,
447 "JIT dispatch cache hit on a no-CUDA build: no kernel to run, \
448 signalling deopt (cache-miss code) instead of echoing input"
449 );
450 DISPATCH_CACHE_MISS
451 }
452 },
453 )?;
454 Ok(())
455}
456
457#[cfg(test)]
458mod tests {
459 use super::*;
460 use std::sync::Arc;
461 use tensor_wasm_jit::cache::{CachedKernel, CompiledHandle, KernelCache};
462 use tensor_wasm_jit::ptx_emit::EmittedPtx;
463 use wasmtime::{Config, Engine, Module, Store};
464
465 /// Minimal store payload for the in-module tests: just wraps an
466 /// [`ArenaState`] so we satisfy [`JitArenaProvider`] without dragging
467 /// in the rest of `InstanceState`.
468 #[derive(Default)]
469 struct TestState {
470 arena: ArenaState,
471 }
472
473 impl JitArenaProvider for TestState {
474 fn jit_arena_mut(&mut self) -> &mut ArenaState {
475 &mut self.arena
476 }
477 }
478
479 impl TenantContext for TestState {
480 fn tenant_id(&self) -> TenantId {
481 TenantId(0)
482 }
483 }
484
485 /// Build a Wasm module that imports `tensor-wasm:jit/host::dispatch` and
486 /// re-exports it as `call_dispatch(fp_lo, fp_hi) -> i32`, hardcoding
487 /// `scratch_ptr` / `args_len` / `results_len` to zero. The test then
488 /// drives the linker with different fingerprints and asserts the
489 /// return code.
490 fn driver_wat() -> &'static str {
491 r#"
492 (module
493 (import "tensor-wasm:jit/host" "dispatch"
494 (func $dispatch (param i64 i64 i32 i32 i32) (result i32)))
495 (import "tensor-wasm:jit/host" "alloc"
496 (func $alloc (param i32) (result i32)))
497 (import "tensor-wasm:jit/host" "free"
498 (func $free (param i32 i32)))
499 (memory (export "memory") 1)
500 (func (export "call_dispatch") (param $lo i64) (param $hi i64) (result i32)
501 (call $dispatch
502 (local.get $lo)
503 (local.get $hi)
504 (i32.const 0)
505 (i32.const 0)
506 (i32.const 0)))
507 )
508 "#
509 }
510
511 fn make_engine() -> Engine {
512 let mut cfg = Config::new();
513 cfg.wasm_simd(true);
514 Engine::new(&cfg).expect("engine")
515 }
516
517 fn make_cache_with(fp: u64, sm_version: u32) -> Arc<KernelCache> {
518 let cache = Arc::new(KernelCache::new());
519 // Test harness uses `Linker<()>`, whose `TenantContext` impl reports
520 // `TenantId(0)`; store under the same tenant so the dispatch lookup
521 // is a hit.
522 cache.put(
523 CacheKey::for_tenant(TenantId(0), fp, sm_version),
524 CachedKernel::new(
525 fp,
526 Arc::new(EmittedPtx {
527 text: "// stub".into(),
528 launch_geometry: (1, 1),
529 }),
530 CompiledHandle::default(),
531 ),
532 );
533 cache
534 }
535
536 /// Expected dispatch return code for a cache HIT under the default
537 /// (no-CUDA) build vs. the `--features cuda` build. On no-CUDA there is no
538 /// real kernel to run, so the stub deopts with the cache-miss code (a trap
539 /// at the trampoline) rather than echoing input as `DISPATCH_OK`.
540 #[cfg(feature = "cuda")]
541 const HIT_CODE: i32 = DISPATCH_OK;
542 #[cfg(not(feature = "cuda"))]
543 const HIT_CODE: i32 = DISPATCH_CACHE_MISS;
544
545 #[test]
546 fn cache_hit_returns_expected_code() {
547 let engine = make_engine();
548 let fp: u64 = 0xDEAD_BEEF_CAFE_BABE;
549 let cache = make_cache_with(fp, DEFAULT_DISPATCH_SM_VERSION);
550 let mut linker: Linker<TestState> = Linker::new(&engine);
551 add_jit_dispatch_to_linker(&mut linker, cache).expect("register dispatch");
552 let mut store = Store::new(&engine, TestState::default());
553 let wasm = wat::parse_str(driver_wat()).expect("wat");
554 let module = Module::new(&engine, &wasm).expect("module");
555 let instance = linker
556 .instantiate(&mut store, &module)
557 .expect("instantiate");
558 let call = instance
559 .get_typed_func::<(i64, i64), i32>(&mut store, "call_dispatch")
560 .expect("typed func");
561 let lo = (fp & 0xFFFF_FFFF) as i64;
562 let hi = (fp >> 32) as i64;
563 let ret = call.call(&mut store, (lo, hi)).expect("call");
564 // No-CUDA: a hit must NOT echo input as DISPATCH_OK — it deopts.
565 assert_eq!(ret, HIT_CODE);
566 }
567
568 #[test]
569 fn cache_miss_returns_minus_one() {
570 let engine = make_engine();
571 // Don't put anything in the cache; the lookup must miss.
572 let cache = Arc::new(KernelCache::new());
573 let mut linker: Linker<TestState> = Linker::new(&engine);
574 add_jit_dispatch_to_linker(&mut linker, cache).expect("register dispatch");
575 let mut store = Store::new(&engine, TestState::default());
576 let wasm = wat::parse_str(driver_wat()).expect("wat");
577 let module = Module::new(&engine, &wasm).expect("module");
578 let instance = linker
579 .instantiate(&mut store, &module)
580 .expect("instantiate");
581 let call = instance
582 .get_typed_func::<(i64, i64), i32>(&mut store, "call_dispatch")
583 .expect("typed func");
584 let ret = call.call(&mut store, (0, 0)).expect("call");
585 assert_eq!(ret, DISPATCH_CACHE_MISS);
586 }
587
588 #[test]
589 fn custom_module_and_fn_name_round_trip() {
590 let engine = make_engine();
591 let fp: u64 = 42;
592 let cache = make_cache_with(fp, 89);
593 let mut linker: Linker<TestState> = Linker::new(&engine);
594 add_jit_dispatch_to_linker_with(
595 &mut linker,
596 cache,
597 "custom:host",
598 "go",
599 "give",
600 "take",
601 89,
602 )
603 .expect("register custom");
604 let wat = r#"
605 (module
606 (import "custom:host" "go"
607 (func $g (param i64 i64 i32 i32 i32) (result i32)))
608 (import "custom:host" "give"
609 (func $a (param i32) (result i32)))
610 (import "custom:host" "take"
611 (func $f (param i32 i32)))
612 (memory (export "memory") 1)
613 (func (export "drive") (param i64 i64) (result i32)
614 (call $g (local.get 0) (local.get 1)
615 (i32.const 0) (i32.const 0) (i32.const 0)))
616 )
617 "#;
618 let mut store = Store::new(&engine, TestState::default());
619 let wasm = wat::parse_str(wat).expect("wat");
620 let module = Module::new(&engine, &wasm).expect("module");
621 let instance = linker
622 .instantiate(&mut store, &module)
623 .expect("instantiate");
624 let call = instance
625 .get_typed_func::<(i64, i64), i32>(&mut store, "drive")
626 .expect("typed func");
627 let lo = (fp & 0xFFFF_FFFF) as i64;
628 let hi = (fp >> 32) as i64;
629 let ret = call.call(&mut store, (lo, hi)).expect("call");
630 assert_eq!(ret, HIT_CODE);
631 }
632
633 #[test]
634 fn fingerprint_with_high_bit_round_trips() {
635 let engine = make_engine();
636 let fp: u64 = 0xFFFF_FFFF_FFFF_FFFF;
637 let cache = make_cache_with(fp, DEFAULT_DISPATCH_SM_VERSION);
638 let mut linker: Linker<TestState> = Linker::new(&engine);
639 add_jit_dispatch_to_linker(&mut linker, cache).expect("register");
640 let mut store = Store::new(&engine, TestState::default());
641 let wasm = wat::parse_str(driver_wat()).expect("wat");
642 let module = Module::new(&engine, &wasm).expect("module");
643 let instance = linker
644 .instantiate(&mut store, &module)
645 .expect("instantiate");
646 let call = instance
647 .get_typed_func::<(i64, i64), i32>(&mut store, "call_dispatch")
648 .expect("typed func");
649 let lo = (fp & 0xFFFF_FFFF) as i64;
650 let hi = (fp >> 32) as i64;
651 let ret = call.call(&mut store, (lo, hi)).expect("call");
652 assert_eq!(ret, HIT_CODE);
653 }
654
655 /// End-to-end test: drive a Wasm function `add(2, 3)` through the
656 /// rewriter and the dispatch trio, with a CUSTOM dispatch import that
657 /// actually performs the addition. Confirms the full marshalling
658 /// round-trip works: alloc, parameter stores, dispatch reads args,
659 /// computes, writes results, trampoline loads results, free.
660 #[test]
661 fn end_to_end_add_returns_sum() {
662 use tensor_wasm_jit::cache::{CacheKey, CachedKernel, CompiledHandle, KernelCache};
663 use tensor_wasm_jit::detector::DetectorConfig;
664 use tensor_wasm_jit::ptx_emit::EmittedPtx;
665 use tensor_wasm_jit::rewrite::{rewrite_wasm, RewriteOptions};
666
667 // Source: hot `add(a, b) -> a + b` decorated with v128 ops in a
668 // loop so the detector flags it for offload. `memory` is exported
669 // so the host imports (dispatch / alloc / free) can find it via
670 // `Caller::get_export("memory")`.
671 let hot_add = r#"
672 (module
673 (memory (export "memory") 1)
674 (func (export "add") (param $a i32) (param $b i32) (result i32)
675 (local $v v128)
676 (loop $L
677 (local.set $v (i32x4.add (local.get $v) (local.get $v)))
678 (local.set $v (i32x4.add (local.get $v) (local.get $v)))
679 (local.set $v (i32x4.add (local.get $v) (local.get $v)))
680 (local.set $v (i32x4.add (local.get $v) (local.get $v)))
681 )
682 (i32.add (local.get $a) (local.get $b))
683 )
684 )
685 "#;
686 let wasm = wat::parse_str(hot_add).expect("wat");
687 let cache = Arc::new(KernelCache::new());
688 let opts = RewriteOptions {
689 detector: DetectorConfig {
690 v128_ratio_threshold: 0.05,
691 min_trip_count: 64,
692 },
693 ..RewriteOptions::default()
694 };
695 let outcome = rewrite_wasm(&wasm, &opts, &cache).expect("rewrite");
696 assert_eq!(
697 outcome.offloaded_functions.len(),
698 1,
699 "the hot add function must be swapped"
700 );
701 let fp = outcome.offloaded_functions[0].fingerprint;
702
703 // Pre-populate the cache (the rewriter does this, but double-check).
704 // The test driver below uses `Linker<()>` whose `TenantContext` impl
705 // reports `TenantId(0)` — store under the same tenant so the runtime
706 // dispatch lookup is a hit. The custom `dispatch` closure further
707 // down ignores the cache anyway and computes the result inline; but
708 // the rewriter-installed trampoline still calls into the default
709 // alloc/free imports keyed off the same store payload.
710 cache.put(
711 CacheKey::for_tenant(TenantId(0), fp, DEFAULT_DISPATCH_SM_VERSION),
712 CachedKernel::new(
713 fp,
714 Arc::new(EmittedPtx {
715 text: "// stub for e2e".into(),
716 launch_geometry: (1, 1),
717 }),
718 CompiledHandle::default(),
719 ),
720 );
721
722 // Build a custom linker with a dispatch that actually adds.
723 let engine = make_engine();
724 let mut linker: Linker<TestState> = Linker::new(&engine);
725
726 // Re-use the standard alloc / free / cache helpers; override dispatch
727 // with a real adder so the test exercises the marshalling round trip
728 // end-to-end. The arena lives in the per-store `TestState`, mirroring
729 // the production path through `InstanceState`.
730 linker
731 .func_wrap(
732 "tensor-wasm:jit/host",
733 "alloc",
734 move |mut caller: Caller<'_, TestState>, size: i32| -> i32 {
735 if size <= 0 {
736 return -1;
737 }
738 let memory = caller
739 .get_export("memory")
740 .and_then(|e| e.into_memory())
741 .expect("memory exported");
742 let cursor_opt = caller.data_mut().jit_arena_mut().bump_cursor;
743 let mem_len = memory.data(&caller).len() as u64;
744 let cursor = match cursor_opt {
745 Some(c) => c,
746 None => {
747 if mem_len < SCRATCH_ARENA_BYTES as u64 {
748 let pages = (SCRATCH_ARENA_BYTES as u64)
749 .div_ceil(65536)
750 .saturating_sub(mem_len / 65536);
751 if pages > 0 {
752 memory.grow(&mut caller, pages).expect("grow");
753 }
754 }
755 let new_len = memory.data(&caller).len() as u64;
756 let top = u32::try_from(new_len).unwrap_or(u32::MAX);
757 caller.data_mut().jit_arena_mut().bump_cursor = Some(top);
758 top
759 }
760 };
761 let aligned = (size as u32 + 7) & !7;
762 let ptr = cursor.checked_sub(aligned).expect("arena room");
763 let st = caller.data_mut().jit_arena_mut();
764 st.bump_cursor = Some(ptr);
765 st.live.push((ptr, aligned));
766 ptr as i32
767 },
768 )
769 .expect("alloc");
770 linker
771 .func_wrap(
772 "tensor-wasm:jit/host",
773 "free",
774 move |mut caller: Caller<'_, TestState>, ptr: i32, _size: i32| {
775 let st = caller.data_mut().jit_arena_mut();
776 if let Some((top_ptr, top_size)) = st.live.last().copied() {
777 if top_ptr == ptr as u32 {
778 st.live.pop();
779 st.bump_cursor = Some(top_ptr + top_size);
780 }
781 }
782 },
783 )
784 .expect("free");
785 linker
786 .func_wrap(
787 "tensor-wasm:jit/host",
788 "dispatch",
789 |mut caller: Caller<'_, TestState>,
790 _fp_lo: i64,
791 _fp_hi: i64,
792 scratch_ptr: i32,
793 args_len: i32,
794 _results_len: i32|
795 -> i32 {
796 // Args layout: i32 a at offset 0, i32 b at offset 4.
797 // Result layout: i32 at offset args_len.
798 let memory = caller
799 .get_export("memory")
800 .and_then(|e| e.into_memory())
801 .expect("memory");
802 let mem = memory.data_mut(&mut caller);
803 let sp = scratch_ptr as usize;
804 let a = i32::from_le_bytes([mem[sp], mem[sp + 1], mem[sp + 2], mem[sp + 3]]);
805 let b =
806 i32::from_le_bytes([mem[sp + 4], mem[sp + 5], mem[sp + 6], mem[sp + 7]]);
807 let sum = a.wrapping_add(b).to_le_bytes();
808 let r = sp + args_len as usize;
809 mem[r..r + 4].copy_from_slice(&sum);
810 DISPATCH_OK
811 },
812 )
813 .expect("dispatch");
814
815 let mut store = Store::new(&engine, TestState::default());
816 let module = Module::new(&engine, &outcome.rewritten_wasm).expect("module");
817 let instance = linker
818 .instantiate(&mut store, &module)
819 .expect("instantiate");
820 let add = instance
821 .get_typed_func::<(i32, i32), i32>(&mut store, "add")
822 .expect("typed");
823 let r = add.call(&mut store, (2, 3)).expect("call add");
824 assert_eq!(
825 r, 5,
826 "end-to-end add(2,3) must marshall args, dispatch, and load result"
827 );
828 }
829
830 /// Regression test for the scratch arena vs. guest static-data
831 /// collision: a guest with a non-trivial data section must still see
832 /// its data byte intact after a JIT scratch alloc. The arena lives in
833 /// the upper `SCRATCH_ARENA_BYTES` of memory; allocations must land
834 /// above the static data region, never on top of it.
835 #[test]
836 fn alloc_does_not_overwrite_guest_static_data() {
837 let engine = make_engine();
838 let cache = Arc::new(KernelCache::new());
839 let mut linker: Linker<TestState> = Linker::new(&engine);
840 add_jit_dispatch_to_linker(&mut linker, cache).expect("register");
841 // Memory: 2 pages (128 KiB) > SCRATCH_ARENA_BYTES (64 KiB), so the
842 // arena_floor sits at 65536 and the lower 64 KiB is "guest data".
843 // We place a sentinel byte at offset 1024 and assert it survives.
844 let wat = r#"
845 (module
846 (import "tensor-wasm:jit/host" "alloc"
847 (func $a (param i32) (result i32)))
848 (memory (export "memory") 2)
849 (data (i32.const 1024) "\AB")
850 (func (export "alloc_one") (param i32) (result i32)
851 (call $a (local.get 0)))
852 (func (export "sentinel") (result i32)
853 (i32.load8_u (i32.const 1024))))
854 "#;
855 let mut store = Store::new(&engine, TestState::default());
856 let wasm = wat::parse_str(wat).expect("wat");
857 let module = Module::new(&engine, &wasm).expect("module");
858 let instance = linker
859 .instantiate(&mut store, &module)
860 .expect("instantiate");
861 let alloc_one = instance
862 .get_typed_func::<i32, i32>(&mut store, "alloc_one")
863 .expect("typed func alloc_one");
864 let sentinel = instance
865 .get_typed_func::<(), i32>(&mut store, "sentinel")
866 .expect("typed func sentinel");
867
868 // A small allocation must succeed and live in the upper 64 KiB,
869 // i.e. strictly above the 64 KiB static-data region. With a 2-page
870 // (128 KiB) memory and SCRATCH_ARENA_BYTES = 64 KiB, the arena
871 // floor is at offset 65536, so any valid ptr must be >= 65536.
872 let p = alloc_one.call(&mut store, 64).expect("alloc 64");
873 assert!(p > 0, "alloc must succeed, got {p}");
874 assert!(
875 (p as u32) >= 65536,
876 "ptr {p} must land in the upper page (>= 64 KiB), above guest static data",
877 );
878 // Sentinel byte at offset 1024 must be untouched.
879 let s = sentinel.call(&mut store, ()).expect("sentinel load");
880 assert_eq!(
881 s, 0xAB,
882 "guest static-data byte must survive JIT scratch alloc"
883 );
884
885 // A second allocation larger than the arena's remaining room must
886 // fail with -1 rather than encroach on guest data.
887 let too_big = alloc_one
888 .call(&mut store, SCRATCH_ARENA_BYTES as i32)
889 .expect("alloc oversize");
890 assert_eq!(
891 too_big, -1,
892 "alloc that would overflow into guest data must return -1"
893 );
894 // Sentinel still intact after the failed alloc.
895 let s2 = sentinel.call(&mut store, ()).expect("sentinel load 2");
896 assert_eq!(s2, 0xAB, "guest static data must survive a refused alloc");
897 }
898
899 /// Alloc/free should round-trip — every successful alloc must produce
900 /// an in-memory pointer, free should not panic, and back-to-back
901 /// allocs should hand out distinct pointers.
902 #[test]
903 fn alloc_free_round_trip() {
904 let engine = make_engine();
905 let cache = Arc::new(KernelCache::new());
906 let mut linker: Linker<TestState> = Linker::new(&engine);
907 add_jit_dispatch_to_linker(&mut linker, cache).expect("register");
908 let wat = r#"
909 (module
910 (import "tensor-wasm:jit/host" "dispatch"
911 (func $d (param i64 i64 i32 i32 i32) (result i32)))
912 (import "tensor-wasm:jit/host" "alloc"
913 (func $a (param i32) (result i32)))
914 (import "tensor-wasm:jit/host" "free"
915 (func $f (param i32 i32)))
916 (memory (export "memory") 1)
917 (func (export "two_alloc") (result i32 i32)
918 (call $a (i32.const 32))
919 (call $a (i32.const 32))))
920 "#;
921 let mut store = Store::new(&engine, TestState::default());
922 let wasm = wat::parse_str(wat).expect("wat");
923 let module = Module::new(&engine, &wasm).expect("module");
924 let instance = linker
925 .instantiate(&mut store, &module)
926 .expect("instantiate");
927 let call = instance
928 .get_typed_func::<(), (i32, i32)>(&mut store, "two_alloc")
929 .expect("typed func");
930 let (p1, p2) = call.call(&mut store, ()).expect("call");
931 assert!(p1 > 0, "first alloc must succeed, got {p1}");
932 assert!(p2 > 0, "second alloc must succeed, got {p2}");
933 assert_ne!(p1, p2, "successive allocs must hand out distinct pointers");
934 }
935}