tensor_wasm_jit/rewrite.rs
1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Craton Software Company
3//! Wasm-to-Wasm rewrite that swaps offload-candidate function bodies with
4//! host-import calls to the JIT dispatch service.
5//!
6//! Sidesteps the wasmtime-cranelift fork question — see `docs/WASMTIME-FORK.md`.
7//!
8//! # ABI (v0.1.0)
9//!
10//! Every offload-candidate function `f(p0, p1, ..., pK) -> (r0, r1, ..., rN)`
11//! is replaced with a trampoline that marshalls arguments through a shared
12//! scratch region in the guest's first linear memory:
13//!
14//! 1. `call __tensor_wasm_jit_alloc(scratch_size)` — host-side arena returns an
15//! `i32` pointer into the guest's first memory (`memory 0`). The host
16//! guarantees the returned pointer plus `scratch_size` does not overlap
17//! any other live allocation.
18//! 2. For each parameter in declaration order, store its raw bytes at
19//! `scratch_ptr + arg_off` using the appropriate `i32.store` /
20//! `i64.store` / `f32.store` / `f64.store`. Offsets are byte-packed in
21//! declared order; i64/f64 take 8 bytes, i32/f32 take 4. There is no
22//! inter-arg padding — the host follows the exact same packing.
23//! 3. `call __tensor_wasm_jit_dispatch(fp_lo: i64, fp_hi: i64, scratch_ptr: i32,
24//! args_byte_len: i32, results_byte_len: i32) -> i32` — runs the
25//! cached PTX kernel (CUDA path) or simulates it on the host (no-CUDA
26//! path) and writes the result bytes to `scratch_ptr + args_byte_len`.
27//! Returns `0` on success, nonzero on error.
28//! 4. For each result in declaration order, load its bytes from
29//! `scratch_ptr + args_byte_len + result_off` and push to the wasm
30//! stack.
31//! 5. `call __tensor_wasm_jit_free(scratch_ptr, scratch_size)` — returns the
32//! arena slot.
33//!
34//! `fingerprint` is the 64-bit BLAKE3-truncated blueprint hash. We pack the
35//! lo/hi 32-bit halves into two `i64` slots so a Wasm 1.0 module can carry
36//! the full 64-bit value without needing the `multi-value` proposal.
37//!
38//! Supported parameter / result types for v0.1.0: **i32, i64, f32, f64**.
39//! v128 and reftypes are explicitly rejected by `enc_val_type` — the
40//! detector also rejects candidates that use unsupported types, so the
41//! rewriter only sees compatible signatures by the time it gets to
42//! `build_trampoline`.
43//!
44//! The host-side implementations of `__tensor_wasm_jit_dispatch`, `__tensor_wasm_jit_alloc`,
45//! and `__tensor_wasm_jit_free` live in `tensor-wasm-exec`'s `jit_dispatch` module.
46
47use std::convert::Infallible;
48use std::sync::Arc;
49
50use rayon::prelude::*;
51use thiserror::Error;
52use tracing::{debug, info};
53use wasm_encoder::reencode::Reencode;
54use wasmparser::{Operator, Parser, Payload};
55
56use tensor_wasm_core::types::TenantId;
57
58use crate::cache::{CacheKey, CachedKernel, CompiledHandle, KernelCache};
59use crate::clif_lower::lower_block;
60use crate::detector::{classify_ops, BlockIR, DetectorConfig, DetectorVerdict, Op};
61use crate::ir::ElemType;
62use crate::ptx_emit::emit;
63
64/// Default host import module name.
65pub const DEFAULT_HOST_MODULE: &str = "tensor-wasm:jit/host";
66/// Default host import field name for the dispatch entry point.
67pub const DEFAULT_HOST_FN: &str = "dispatch";
68/// Host import name for the scratch-arena allocator.
69pub const DEFAULT_HOST_ALLOC_FN: &str = "alloc";
70/// Host import name for the scratch-arena deallocator.
71pub const DEFAULT_HOST_FREE_FN: &str = "free";
72/// Default sm_version the rewriter pre-populates kernels for.
73pub const DEFAULT_SM_VERSION: u32 = 80;
74
75/// Options controlling the rewrite.
76#[derive(Debug, Clone)]
77pub struct RewriteOptions {
78 /// Host import module (defaults to `tensor-wasm:jit/host`).
79 pub host_module: String,
80 /// Host import function name for `dispatch` (defaults to `dispatch`).
81 pub host_fn: String,
82 /// Host import function name for the scratch allocator (defaults to `alloc`).
83 pub host_alloc_fn: String,
84 /// Host import function name for the scratch deallocator (defaults to `free`).
85 pub host_free_fn: String,
86 /// CUDA compute capability the pre-populated kernels are compiled for.
87 pub sm_version: u32,
88 /// Owning tenant the rewrite-time cache pre-population is keyed under.
89 ///
90 /// The rewriter pre-populates the [`KernelCache`] with the emitted PTX
91 /// for every offloaded function so the *first* runtime dispatch hits
92 /// straight away instead of missing and re-emitting. That hit only
93 /// lands if the pre-populated entry's [`CacheKey`] tenant matches the
94 /// tenant the runtime dispatch looks up under — cache keys are
95 /// tenant-scoped (see [`CacheKey`] for the cross-tenant confused-deputy
96 /// primitive this enforces). Thread the owning tenant in here so the
97 /// pre-populated entries are reachable at runtime.
98 ///
99 /// Defaults to `TenantId(0)` for backward compatibility: callers that
100 /// do not yet know the owning tenant get the historical behaviour
101 /// (entries land under the `TenantId(0)` placeholder and the runtime
102 /// misses + re-emits on first call).
103 pub tenant_id: TenantId,
104 /// Detector configuration used to classify each function body. Use this
105 /// to lower thresholds in tests or to tune offload aggressiveness in
106 /// production deployments.
107 pub detector: DetectorConfig,
108}
109
110impl Default for RewriteOptions {
111 fn default() -> Self {
112 Self {
113 host_module: DEFAULT_HOST_MODULE.into(),
114 host_fn: DEFAULT_HOST_FN.into(),
115 host_alloc_fn: DEFAULT_HOST_ALLOC_FN.into(),
116 host_free_fn: DEFAULT_HOST_FREE_FN.into(),
117 sm_version: DEFAULT_SM_VERSION,
118 tenant_id: TenantId(0),
119 detector: DetectorConfig::default(),
120 }
121 }
122}
123
124/// One swapped function.
125#[derive(Debug, Clone, PartialEq, Eq)]
126pub struct OffloadedFunction {
127 /// Index of the function in the ORIGINAL (pre-rewrite) function index
128 /// space. The post-rewrite index is shifted by the number of imports
129 /// the rewriter added (3: dispatch, alloc, free).
130 pub function_index: u32,
131 /// Blueprint fingerprint — also the [`KernelCache`] key the dispatch
132 /// import looks up at runtime.
133 pub fingerprint: u64,
134 /// Number of original operators in the function body the swap replaced.
135 pub original_op_count: usize,
136}
137
138/// Outcome of a rewrite.
139#[derive(Debug, Clone)]
140pub struct RewriteOutcome {
141 /// The post-rewrite Wasm bytes — pass these to Wasmtime in place of the
142 /// original.
143 pub rewritten_wasm: Vec<u8>,
144 /// Functions whose bodies were swapped for a dispatch trampoline.
145 pub offloaded_functions: Vec<OffloadedFunction>,
146 /// Total number of defined functions in the original module.
147 pub total_defined_functions: u32,
148}
149
150/// Errors raised by the rewriter.
151#[derive(Debug, Error)]
152pub enum RewriteError {
153 /// The Wasm bytes failed to parse.
154 #[error("wasmparser: {0}")]
155 Parse(String),
156 /// The lower→emit pipeline rejected a candidate (e.g. unsupported op).
157 /// The candidate is silently skipped — this variant is returned only when
158 /// the rewriter is configured to be strict (currently never, but reserved).
159 #[error("lower: {0}")]
160 Lower(String),
161 /// Re-encoding the module failed.
162 #[error("reencode: {0}")]
163 Reencode(String),
164 /// An offload candidate's signature couldn't be safely synthesised as a
165 /// trampoline (e.g. unsupported result type). The candidate is skipped
166 /// rather than producing invalid Wasm.
167 #[error("trampoline: {0}")]
168 Trampoline(String),
169}
170
171impl<E: std::fmt::Display> From<wasm_encoder::reencode::Error<E>> for RewriteError {
172 fn from(e: wasm_encoder::reencode::Error<E>) -> Self {
173 RewriteError::Reencode(format!("{e}"))
174 }
175}
176
177/// Per-function record from the pre-pass analysis.
178#[derive(Debug, Clone)]
179struct FuncInfo {
180 /// Type index in the original module.
181 type_index: u32,
182 /// Detector verdict for this function body.
183 verdict: DetectorVerdict,
184 /// Blueprint fingerprint (only meaningful when verdict is `Offload`).
185 fingerprint: Option<u64>,
186 /// Op count walked (for diagnostics).
187 op_count: usize,
188}
189
190/// Mirror of `tensor_wasm_exec::auto_offload::op_to_detector_op` — duplicated here
191/// because `tensor-wasm-jit` is intentionally a leaf dependency that does not
192/// reference `tensor-wasm-exec`.
193fn op_to_detector_op(op: &Operator<'_>) -> Op {
194 use wasmparser::Operator::*;
195 match op {
196 V128Load { .. } => Op::Load,
197 V128Store { .. } => Op::Store,
198 F32Add | I32Add | I64Add | F64Add => Op::ScalarAdd,
199 F32Mul | I32Mul | I64Mul | F64Mul => Op::ScalarMul,
200 I32Load { .. } | I64Load { .. } | F32Load { .. } | F64Load { .. } => Op::Load,
201 I32Store { .. } | I64Store { .. } | F32Store { .. } | F64Store { .. } => Op::Store,
202 Br { .. }
203 | BrIf { .. }
204 | BrTable { .. }
205 | If { .. }
206 | Else
207 | Loop { .. }
208 | Block { .. } => Op::Branch,
209 // jit LOW fix (finding 8): `ReturnCallIndirect` belongs in the
210 // `Op::Call` arm alongside the other call forms — previously it fell
211 // through to `Op::Other`, undercounting the call density of a
212 // function whose tail call is indirect.
213 Call { .. } | CallIndirect { .. } | ReturnCall { .. } | ReturnCallIndirect { .. } => {
214 Op::Call
215 }
216
217 // jit CRITICAL fix: each SIMD opcode maps to a `V128Add`/`V128Mul`
218 // carrying its true element type AND lane count, rather than
219 // collapsing every shape onto a bare `V128Add` that the emitter
220 // then blindly lowered to `add.f32`.
221 //
222 // FAIL CLOSED: only the element widths the PTX emitter can lower
223 // *end-to-end and correctly* are routed to a SIMD op. The emitter's
224 // load/store/compute model is currently coherent for 4-byte lanes
225 // (`f32` and `i32`); every other width (`f64x2`, `i64x2`, `i16x8`,
226 // `i8x16`) is routed to [`Op::Other`] so the function stays on the
227 // CPU path instead of being miscompiled. As wider emitter support
228 // lands, move the corresponding arms below the `Op::Other` line.
229 F32x4Add => Op::V128Add {
230 lane_ty: ElemType::F32,
231 lanes: 4,
232 },
233 I32x4Add => Op::V128Add {
234 lane_ty: ElemType::I32,
235 lanes: 4,
236 },
237 F32x4Mul => Op::V128Mul {
238 lane_ty: ElemType::F32,
239 lanes: 4,
240 },
241 I32x4Mul => Op::V128Mul {
242 lane_ty: ElemType::I32,
243 lanes: 4,
244 },
245
246 // FAIL CLOSED: element widths the emitter cannot yet lower without
247 // miscompiling are deliberately NOT classified as SIMD. They count
248 // toward `Op::Other` (denominator only), so a function dominated by
249 // them stays on the CPU path. (Previously these were lowered to a
250 // single `add.f32` kernel — a silent miscompile.)
251 F64x2Add | I64x2Add | I16x8Add | I8x16Add | F64x2Mul | I64x2Mul | I16x8Mul => Op::Other,
252
253 // Anything else — local.get, drop, const, … — is classified as
254 // [`Op::Other`] so the v128 ratio is computed honestly. Previously
255 // this fell through to `Op::ScalarAdd`, which inflated the apparent
256 // arithmetic density of non-arithmetic functions.
257 _ => Op::Other,
258 }
259}
260
261/// Decoded function type — just what we need to synthesise a trampoline.
262#[derive(Debug, Clone)]
263struct DecodedFuncType {
264 params: Vec<wasmparser::ValType>,
265 results: Vec<wasmparser::ValType>,
266}
267
268/// Per-function pre-pass state. Captures everything we need from the
269/// sequential parse pass so that the heavy `lower_block` + `emit` work can
270/// be deferred to a single data-parallel pass after parsing finishes.
271///
272/// The fields are public-to-this-module only; the parallel emit closure
273/// reads them by value (it owns its slot via `into_par_iter`).
274struct PreFuncInfo {
275 /// Type index in the original module — copied straight into the
276 /// final `FuncInfo` slot.
277 type_index: u32,
278 /// Detector verdict for the body. Determines whether the parallel
279 /// emit pass does any work for this slot.
280 verdict: DetectorVerdict,
281 /// Op count walked (for diagnostics) — copied straight into the
282 /// final `FuncInfo`.
283 op_count: usize,
284 /// Function index in the *global* (post-import) function index space,
285 /// used only for diagnostics in info/debug logs.
286 func_index_in_global_space: u32,
287 /// Trip-count guess: `Some(128)` if the body contained a `Loop`,
288 /// otherwise `None`. Fed verbatim into the `BlockIR` passed to
289 /// `lower_block`.
290 trip_guess: Option<u64>,
291 /// The detector op stream — needed both for the `BlockIR::new` that
292 /// feeds `lower_block` and (already-walked) for `classify`. We carry
293 /// the full stream here even though `classify` ran during the parse
294 /// pass, because the parallel emit needs to re-filter it down to the
295 /// `{V128*, Load, Store}` taxonomy that `lower_block` accepts.
296 detector_ops: Vec<Op>,
297 /// Did the surrounding module have at least one memory? Captured at
298 /// parse time so the parallel emit closure can short-circuit on
299 /// memory-less modules without needing access to the analyser's
300 /// mutable state. The trampoline reads / writes memory 0 — without it
301 /// the swap would emit invalid wasm.
302 has_memory: bool,
303 /// Did the function's signature pass the supported-primitives gate?
304 /// Resolved during the parse pass against the (then-complete) types
305 /// table. Captured here so the parallel emit closure stays purely
306 /// functional over its slot.
307 signature_ok: bool,
308}
309
310/// Pre-pass: walk the module, build the type table, count function imports,
311/// classify each defined function, then run lower→emit for offload
312/// candidates in parallel via `rayon`, and pre-populate the cache.
313///
314/// **Order preservation**: the rewriter downstream assumes
315/// `func_infos[i]` corresponds to the *i*-th `CodeSectionEntry`. To honour
316/// this without serialising the heavy emit work, we collect
317/// `PreFuncInfo` slots in sequential parse order, run lower+emit through
318/// `into_par_iter().enumerate()`, and re-sort by index before reducing
319/// into `func_infos`. `cache.put` is internally lock-free
320/// (`dashmap` + `parking_lot::Mutex` only on eviction), so the parallel
321/// pass also commits to the cache without a final serial fold.
322fn analyse(
323 wasm: &[u8],
324 opts: &RewriteOptions,
325 cache: &KernelCache,
326) -> Result<AnalyseOutcome, RewriteError> {
327 let mut types: Vec<Option<DecodedFuncType>> = Vec::new();
328 let mut function_type_indices: Vec<u32> = Vec::new();
329 let mut num_function_imports: u32 = 0;
330 let mut pre_infos: Vec<PreFuncInfo> = Vec::new();
331 let mut defined_function_cursor: usize = 0;
332 // The trampoline reads / writes memory 0 via `i32.store` etc. — if the
333 // input module has no memory section the trampoline body won't validate
334 // even if the rest of the module would. Track whether we saw one so we
335 // can suppress swaps on memory-less modules rather than emit invalid
336 // wasm.
337 let mut has_memory: bool = false;
338
339 for payload in Parser::new(0).parse_all(wasm) {
340 let payload = payload.map_err(|e| RewriteError::Parse(format!("{e}")))?;
341 match payload {
342 Payload::TypeSection(reader) => {
343 for rec_group in reader {
344 let rec = rec_group.map_err(|e| RewriteError::Parse(format!("{e}")))?;
345 for sub in rec.into_types() {
346 // We only care about function types; everything else
347 // (array/struct/cont) records `None` so trampoline
348 // synthesis declines those candidates.
349 let decoded = match sub.composite_type.inner {
350 wasmparser::CompositeInnerType::Func(f) => Some(DecodedFuncType {
351 params: f.params().to_vec(),
352 results: f.results().to_vec(),
353 }),
354 _ => None,
355 };
356 types.push(decoded);
357 }
358 }
359 }
360 Payload::ImportSection(reader) => {
361 for import in reader {
362 let import = import.map_err(|e| RewriteError::Parse(format!("{e}")))?;
363 if matches!(import.ty, wasmparser::TypeRef::Func(_)) {
364 num_function_imports += 1;
365 }
366 if matches!(import.ty, wasmparser::TypeRef::Memory(_)) {
367 has_memory = true;
368 }
369 }
370 }
371 Payload::MemorySection(reader) if reader.count() > 0 => {
372 has_memory = true;
373 }
374 Payload::FunctionSection(reader) => {
375 for ty_idx in reader {
376 let ty_idx = ty_idx.map_err(|e| RewriteError::Parse(format!("{e}")))?;
377 function_type_indices.push(ty_idx);
378 }
379 }
380 Payload::CodeSectionEntry(body) => {
381 let type_index = function_type_indices
382 .get(defined_function_cursor)
383 .copied()
384 .ok_or_else(|| {
385 RewriteError::Parse(format!(
386 "code body {defined_function_cursor} has no matching function type"
387 ))
388 })?;
389 let mut ops_reader = body
390 .get_operators_reader()
391 .map_err(|e| RewriteError::Parse(format!("{e}")))?;
392 let mut detector_ops = Vec::new();
393 let mut saw_loop = false;
394 while !ops_reader.eof() {
395 match ops_reader.read() {
396 Ok(op) => {
397 if matches!(op, wasmparser::Operator::Loop { .. }) {
398 saw_loop = true;
399 }
400 detector_ops.push(op_to_detector_op(&op));
401 }
402 Err(_) => break,
403 }
404 }
405 let func_index_in_global_space =
406 num_function_imports + defined_function_cursor as u32;
407 // Only set a trip-count guess if the body actually had a
408 // `Loop`. A no-loop function inheriting `Some(128)`
409 // misleads the detector into approving cold straight-line
410 // code.
411 let trip_guess = if saw_loop { Some(128) } else { None };
412 // jit PERF fix (finding 11): classify over the borrowed op
413 // slice instead of cloning `detector_ops` into a throwaway
414 // `BlockIR`. The original vector is moved into the
415 // per-function slot below unchanged.
416 let verdict = classify_ops(&detector_ops, trip_guess, &opts.detector);
417 let op_count = detector_ops.len();
418 // Signature check is parse-time pure: it only reads
419 // `types[type_index]`. Captured here so the parallel emit
420 // closure stays free of references to the analyser state.
421 let signature_ok = types
422 .get(type_index as usize)
423 .and_then(|t| t.as_ref())
424 .map(|t| {
425 t.params.iter().all(is_supported_primitive)
426 && t.results.iter().all(is_supported_primitive)
427 })
428 .unwrap_or(false);
429 pre_infos.push(PreFuncInfo {
430 type_index,
431 verdict,
432 op_count,
433 func_index_in_global_space,
434 trip_guess,
435 detector_ops,
436 has_memory,
437 signature_ok,
438 });
439 defined_function_cursor += 1;
440 }
441 _ => {}
442 }
443 }
444
445 // Parallel pass: lower + emit each offload candidate. We deliberately
446 // `into_par_iter` (consumes `pre_infos`) so each closure invocation owns
447 // its `detector_ops` `Vec` and we avoid any borrow-checker fights with
448 // the captured slot data. `enumerate()` preserves the slot index so the
449 // collected results re-sort into the parse-time order the rewriter's
450 // downstream walk expects (`func_infos[i] ↔ CodeSectionEntry[i]`).
451 //
452 // `cache.put` is internally thread-safe (dashmap is the hot path; the
453 // LRU mutex is contended only on capacity eviction). We therefore call
454 // it from inside the parallel closure rather than collecting kernels
455 // and looping serially — the synthesis cost dominates and the cache
456 // commit is cheap.
457 //
458 // Each iteration produces `(slot_index, FuncInfo)`; we re-collect into
459 // a `Vec` and sort by `slot_index` so the final assignment to
460 // `func_infos` is in source order. Sorting `N` `(u32, FuncInfo)` tuples
461 // is `O(N log N)` and trivially dominated by the parallel emit work.
462 let n_slots = pre_infos.len();
463 let mut indexed: Vec<(usize, FuncInfo)> = pre_infos
464 .into_par_iter()
465 .enumerate()
466 .map(|(idx, pre)| {
467 let fingerprint = emit_for_slot(&pre, opts, cache);
468 (
469 idx,
470 FuncInfo {
471 type_index: pre.type_index,
472 verdict: pre.verdict,
473 fingerprint,
474 op_count: pre.op_count,
475 },
476 )
477 })
478 .collect();
479 indexed.sort_by_key(|(idx, _)| *idx);
480 debug_assert_eq!(
481 indexed.len(),
482 n_slots,
483 "rayon emit pass returned a different number of slots than it consumed"
484 );
485 let func_infos: Vec<FuncInfo> = indexed.into_iter().map(|(_, fi)| fi).collect();
486
487 Ok(AnalyseOutcome {
488 types,
489 num_function_imports,
490 func_infos,
491 })
492}
493
494/// Per-slot lower + emit work. Pure over its inputs except for the
495/// pre-populating `cache.put` (which is internally synchronised). Returns
496/// the blueprint fingerprint on success, `None` on any rejection — the
497/// rejection reasons are logged at the same severities as the previous
498/// inline emit path, so existing log-scraping continues to work.
499fn emit_for_slot(pre: &PreFuncInfo, opts: &RewriteOptions, cache: &KernelCache) -> Option<u64> {
500 if !matches!(pre.verdict, DetectorVerdict::Offload) {
501 return None;
502 }
503 if !pre.has_memory {
504 debug!(
505 target: "tensor_wasm_jit::rewrite",
506 function = pre.func_index_in_global_space,
507 "offload candidate rejected: module has no memory for trampoline marshalling"
508 );
509 return None;
510 }
511 if !pre.signature_ok {
512 debug!(
513 target: "tensor_wasm_jit::rewrite",
514 function = pre.func_index_in_global_space,
515 "offload candidate rejected: unsupported parameter/result type"
516 );
517 return None;
518 }
519 // The lowering pass refuses anything outside the {V128*, Load, Store}
520 // taxonomy. Filter the full op stream down to those before handing it
521 // off so the analyser doesn't trip on local.get / br / call noise that
522 // the detector already weighed.
523 let lower_block_input = BlockIR::new(
524 format!("func{}", pre.func_index_in_global_space),
525 pre.detector_ops
526 .iter()
527 .copied()
528 .filter(|o| {
529 matches!(
530 o,
531 Op::V128Add { .. }
532 | Op::V128Mul { .. }
533 | Op::V128Fma { .. }
534 | Op::Load
535 | Op::Store
536 )
537 })
538 .collect(),
539 pre.trip_guess,
540 );
541 match lower_block(&lower_block_input) {
542 Ok(blueprint) => match emit(&blueprint) {
543 Ok(ptx) => {
544 let fp = blueprint.fingerprint();
545 // Rewrite-time pre-population: key the entry under the
546 // owning tenant supplied in `RewriteOptions::tenant_id` so
547 // the first runtime dispatch (which looks up under that same
548 // tenant) actually HITS this pre-populated entry instead of
549 // missing and re-emitting. Defaults to the historical
550 // `TenantId(0)` placeholder when the caller hasn't plumbed a
551 // tenant through. Cache keys are tenant-scoped — see
552 // `CacheKey` docs for the cross-tenant confused-deputy
553 // primitive this enforces.
554 let key = CacheKey::for_tenant(opts.tenant_id, fp, opts.sm_version);
555 cache.put(
556 key,
557 CachedKernel::new(fp, Arc::new(ptx), CompiledHandle::default()),
558 );
559 info!(
560 target: "tensor_wasm_jit::rewrite",
561 function = pre.func_index_in_global_space,
562 op_count = pre.op_count,
563 fingerprint = fp,
564 "pre-populated kernel cache for offload candidate"
565 );
566 Some(fp)
567 }
568 Err(e) => {
569 // Emission refused (e.g. MatMul not yet implemented) — keep
570 // this function on the CPU path. This is the deopt-at-rewrite
571 // signal at the emit stage.
572 debug!(
573 target: "tensor_wasm_jit::rewrite",
574 function = pre.func_index_in_global_space,
575 op_count = pre.op_count,
576 reason = %e,
577 "offload candidate rejected by PTX emitter"
578 );
579 None
580 }
581 },
582 Err(e) => {
583 // Lowering refused — keep this function on the CPU path. This
584 // is the deopt-at-rewrite signal.
585 debug!(
586 target: "tensor_wasm_jit::rewrite",
587 function = pre.func_index_in_global_space,
588 op_count = pre.op_count,
589 reason = %e,
590 "offload candidate rejected by lowering"
591 );
592 None
593 }
594 }
595}
596
597struct AnalyseOutcome {
598 types: Vec<Option<DecodedFuncType>>,
599 num_function_imports: u32,
600 func_infos: Vec<FuncInfo>,
601}
602
603/// True if this val type is a supported primitive for the v0.1.0 ABI.
604fn is_supported_primitive(v: &wasmparser::ValType) -> bool {
605 matches!(
606 v,
607 wasmparser::ValType::I32
608 | wasmparser::ValType::I64
609 | wasmparser::ValType::F32
610 | wasmparser::ValType::F64
611 )
612}
613
614/// Convert a [`wasmparser::ValType`] to its [`wasm_encoder::ValType`]
615/// counterpart. We refuse `Ref(_)` and `V128` — the trampoline can't
616/// marshall those through a byte-packed scratch region without richer
617/// plumbing.
618fn enc_val_type(v: wasmparser::ValType) -> Result<wasm_encoder::ValType, RewriteError> {
619 match v {
620 wasmparser::ValType::I32 => Ok(wasm_encoder::ValType::I32),
621 wasmparser::ValType::I64 => Ok(wasm_encoder::ValType::I64),
622 wasmparser::ValType::F32 => Ok(wasm_encoder::ValType::F32),
623 wasmparser::ValType::F64 => Ok(wasm_encoder::ValType::F64),
624 wasmparser::ValType::V128 => Err(RewriteError::Trampoline(
625 "v128 result not supported by dispatch trampoline".into(),
626 )),
627 wasmparser::ValType::Ref(_) => Err(RewriteError::Trampoline(
628 "reference result not supported by dispatch trampoline".into(),
629 )),
630 }
631}
632
633/// Byte size of a supported value type. Returns `Err` on unsupported types —
634/// call sites typically pre-validate with [`is_supported_primitive`], but the
635/// fallible signature means a malformed input can never panic the rewriter.
636fn val_type_size(v: wasmparser::ValType) -> Result<u32, RewriteError> {
637 match v {
638 wasmparser::ValType::I32 | wasmparser::ValType::F32 => Ok(4),
639 wasmparser::ValType::I64 | wasmparser::ValType::F64 => Ok(8),
640 _ => Err(RewriteError::Trampoline(format!(
641 "val_type_size: unsupported type {v:?}"
642 ))),
643 }
644}
645
646/// Aligned offset for `val_type` in a packed byte buffer. We use natural
647/// alignment (4 for i32/f32, 8 for i64/f64) so the host-side reads on
648/// systems that care about alignment never trap.
649fn aligned_advance(off: u32, v: wasmparser::ValType) -> Result<u32, RewriteError> {
650 let align = val_type_size(v)?;
651 let mask = align - 1;
652 let summed = off.checked_add(mask).ok_or_else(|| {
653 RewriteError::Trampoline(format!(
654 "aligned_advance: offset overflow (off={off}, mask={mask})"
655 ))
656 })?;
657 Ok(summed & !mask)
658}
659
660/// Layout of a packed argument or result region: total byte length plus a
661/// per-element offset table.
662struct PackedLayout {
663 offsets: Vec<u32>,
664 total_bytes: u32,
665}
666
667fn pack_layout(types: &[wasmparser::ValType]) -> Result<PackedLayout, RewriteError> {
668 let mut offsets = Vec::with_capacity(types.len());
669 let mut off = 0u32;
670 for t in types {
671 let aligned = aligned_advance(off, *t)?;
672 offsets.push(aligned);
673 let size = val_type_size(*t)?;
674 off = aligned.checked_add(size).ok_or_else(|| {
675 RewriteError::Trampoline(format!(
676 "pack_layout: cursor overflow (aligned={aligned}, size={size})"
677 ))
678 })?;
679 }
680 // Round the total up to 8 bytes so the results region following the
681 // args region also starts 8-aligned.
682 let total_bytes = off.checked_add(7).ok_or_else(|| {
683 RewriteError::Trampoline(format!(
684 "pack_layout: total-bytes round-up overflow (off={off})"
685 ))
686 })? & !7u32;
687 Ok(PackedLayout {
688 offsets,
689 total_bytes,
690 })
691}
692
693/// Indices of the three host imports the rewriter inserts.
694struct DispatchImports {
695 dispatch: u32,
696 alloc: u32,
697 free: u32,
698}
699
700/// Build a trampoline body for an offload-swapped function. See the
701/// module-level docs for the ABI this implements.
702fn build_trampoline(
703 fingerprint: u64,
704 imports: &DispatchImports,
705 params: &[wasmparser::ValType],
706 results: &[wasmparser::ValType],
707) -> Result<wasm_encoder::Function, RewriteError> {
708 // Validate the signature up-front; emit a `Trampoline` error rather
709 // than fabricate an invalid function body.
710 for t in params.iter().chain(results.iter()) {
711 if !is_supported_primitive(t) {
712 return Err(RewriteError::Trampoline(format!(
713 "unsupported type in signature: {t:?}",
714 )));
715 }
716 }
717
718 let args_layout = pack_layout(params)?;
719 let results_layout = pack_layout(results)?;
720 let scratch_size = args_layout
721 .total_bytes
722 .checked_add(results_layout.total_bytes)
723 .ok_or_else(|| RewriteError::Trampoline("scratch size overflow".into()))?;
724
725 // jit LOW fix (finding 9): the trampoline emits `scratch_size`,
726 // `args_layout.total_bytes`, and `results_layout.total_bytes` as
727 // `i32.const` operands via `as i32`. A `u32` value above `i32::MAX`
728 // would wrap to a NEGATIVE i32, so the host alloc/free/dispatch
729 // imports would receive a bogus (negative) size. Validate the total —
730 // which dominates the two halves (`args`/`results` are each ≤
731 // `scratch_size`) — fits in a non-negative i32 and refuse the swap
732 // otherwise so the function stays on the CPU path.
733 if scratch_size > i32::MAX as u32 {
734 return Err(RewriteError::Trampoline(format!(
735 "scratch size {scratch_size} exceeds i32::MAX; trampoline cannot encode it"
736 )));
737 }
738
739 // Two i32 locals: scratch_ptr (idx = params.len()) and rc (idx =
740 // params.len() + 1) for the dispatch return-code trap below.
741 let scratch_local_idx: u32 = params.len() as u32;
742 let rc_local_idx: u32 = scratch_local_idx + 1;
743 let mut func = wasm_encoder::Function::new(std::iter::once((2u32, wasm_encoder::ValType::I32)));
744
745 use wasm_encoder::Instruction as I;
746
747 // scratch_ptr = __tensor_wasm_jit_alloc(scratch_size)
748 func.instruction(&I::I32Const(scratch_size as i32));
749 func.instruction(&I::Call(imports.alloc));
750 func.instruction(&I::LocalSet(scratch_local_idx));
751
752 // For each parameter: scratch_ptr + arg_off ; param ; <type>.store
753 for (i, ty) in params.iter().enumerate() {
754 let off = args_layout.offsets[i];
755 func.instruction(&I::LocalGet(scratch_local_idx));
756 func.instruction(&I::LocalGet(i as u32));
757 let memarg = wasm_encoder::MemArg {
758 offset: off as u64,
759 align: log2_align(*ty),
760 memory_index: 0,
761 };
762 match ty {
763 wasmparser::ValType::I32 => {
764 func.instruction(&I::I32Store(memarg));
765 }
766 wasmparser::ValType::I64 => {
767 func.instruction(&I::I64Store(memarg));
768 }
769 wasmparser::ValType::F32 => {
770 func.instruction(&I::F32Store(memarg));
771 }
772 wasmparser::ValType::F64 => {
773 func.instruction(&I::F64Store(memarg));
774 }
775 _ => unreachable!("validated above"),
776 }
777 }
778
779 // Pack fingerprint as two i64 halves.
780 let fp_lo = (fingerprint & 0xFFFF_FFFF) as i64;
781 let fp_hi = (fingerprint >> 32) as i64;
782
783 // call __tensor_wasm_jit_dispatch(fp_lo, fp_hi, scratch_ptr, args_len, results_len) -> i32
784 func.instruction(&I::I64Const(fp_lo));
785 func.instruction(&I::I64Const(fp_hi));
786 func.instruction(&I::LocalGet(scratch_local_idx));
787 func.instruction(&I::I32Const(args_layout.total_bytes as i32));
788 func.instruction(&I::I32Const(results_layout.total_bytes as i32));
789 func.instruction(&I::Call(imports.dispatch));
790 // Capture the i32 return code from dispatch into `rc_local_idx` and
791 // trap the guest if it's nonzero. Previously this dropped the rc
792 // silently, so a deopted offload would feed zero-filled result bytes
793 // back to the caller and mask host-side failures. The trap surfaces
794 // the failure as a wasm trap the embedder catches with the rest of
795 // its trap handling.
796 //
797 // local.tee $rc ;; consumes the dispatch i32, leaves a copy
798 // i32.const 0
799 // i32.ne
800 // if
801 // local.get $scratch ;; free the arena slot on the trap path too —
802 // i32.const scratch_size ;; the `unreachable` below aborts the guest
803 // call $free ;; but the host arena must still reclaim it,
804 // unreachable ;; otherwise a deopt-storm leaks scratch.
805 // end
806 //
807 // The `if` body is stack-neutral: the `i32.ne` result was consumed by
808 // `if`, so the block opens with an empty operand stack. `free` pushes
809 // its two args, `call $free` consumes them and returns nothing, leaving
810 // the stack empty again before `unreachable`. (The `unreachable` makes
811 // the rest of the block dead, but the encoder still requires the body
812 // to type-check up to that point.)
813 func.instruction(&I::LocalTee(rc_local_idx));
814 func.instruction(&I::I32Const(0));
815 func.instruction(&I::I32Ne);
816 func.instruction(&I::If(wasm_encoder::BlockType::Empty));
817 func.instruction(&I::LocalGet(scratch_local_idx));
818 func.instruction(&I::I32Const(scratch_size as i32));
819 func.instruction(&I::Call(imports.free));
820 func.instruction(&I::Unreachable);
821 func.instruction(&I::End);
822
823 // For each result: scratch_ptr ; <type>.load (offset = args_len + result_off)
824 for (i, ty) in results.iter().enumerate() {
825 let off = args_layout.total_bytes + results_layout.offsets[i];
826 func.instruction(&I::LocalGet(scratch_local_idx));
827 let memarg = wasm_encoder::MemArg {
828 offset: off as u64,
829 align: log2_align(*ty),
830 memory_index: 0,
831 };
832 match ty {
833 wasmparser::ValType::I32 => {
834 func.instruction(&I::I32Load(memarg));
835 }
836 wasmparser::ValType::I64 => {
837 func.instruction(&I::I64Load(memarg));
838 }
839 wasmparser::ValType::F32 => {
840 func.instruction(&I::F32Load(memarg));
841 }
842 wasmparser::ValType::F64 => {
843 func.instruction(&I::F64Load(memarg));
844 }
845 _ => unreachable!("validated above"),
846 }
847 }
848
849 // __tensor_wasm_jit_free(scratch_ptr, scratch_size)
850 //
851 // Emission order is: alloc -> stores -> dispatch -> drop -> loads ->
852 // free. Why is loads-then-free safe? The loads push N result values
853 // onto the wasm stack. Free's two arguments (scratch_ptr, scratch_size)
854 // are pushed AFTER those results, then consumed by `call`. The wasm
855 // stack is LIFO; after free returns (no return value), the stack top
856 // is exactly the N result values — which is the trampoline's declared
857 // return signature.
858 func.instruction(&I::LocalGet(scratch_local_idx));
859 func.instruction(&I::I32Const(scratch_size as i32));
860 func.instruction(&I::Call(imports.free));
861
862 func.instruction(&I::End);
863 Ok(func)
864}
865
866/// log2 of the natural alignment for a memory store/load. The wasm spec
867/// uses this as the alignment hint encoded in `memarg`.
868fn log2_align(v: wasmparser::ValType) -> u32 {
869 match v {
870 wasmparser::ValType::I32 | wasmparser::ValType::F32 => 2,
871 wasmparser::ValType::I64 | wasmparser::ValType::F64 => 3,
872 _ => 0,
873 }
874}
875
876/// Stateful re-encoder that:
877/// - Appends three host import types (dispatch, alloc, free) to the type
878/// section.
879/// - Appends three imports to the import section.
880/// - Shifts every defined function index by +3.
881/// - Swaps offload-candidate function bodies for trampolines.
882struct TensorWasmRewriter<'a> {
883 opts: &'a RewriteOptions,
884 /// `num_existing_function_imports` — anything below this is an imported
885 /// function and its index is preserved; anything at or above shifts by
886 /// `imports_added` (always 3 in v0.1.0).
887 num_function_imports: u32,
888 /// Type indices assigned to the dispatch / alloc / free imports.
889 dispatch_type_index: u32,
890 alloc_type_index: u32,
891 free_type_index: u32,
892 /// Function indices assigned to the three imports.
893 imports: DispatchImports,
894 /// Number of function imports the rewriter inserts (always 3).
895 imports_added: u32,
896 /// Per-defined-function info from the pre-pass (indexed by defined-func
897 /// cursor, same order as the code section).
898 func_infos: &'a [FuncInfo],
899 /// Type table from the pre-pass.
900 types: &'a [Option<DecodedFuncType>],
901 /// Cursor tracking which defined function body we're currently re-emitting.
902 code_cursor: usize,
903 /// Records of successful swaps (consumed by [`rewrite_wasm`]).
904 swapped: Vec<OffloadedFunction>,
905 /// Has the rewriter already appended its dispatch type to the type
906 /// section?
907 type_section_appended: bool,
908 /// Same for the import section.
909 import_section_appended: bool,
910}
911
912impl<'a> TensorWasmRewriter<'a> {
913 fn shifted_fn_index(&self, orig: u32) -> u32 {
914 if orig < self.num_function_imports {
915 orig
916 } else {
917 orig + self.imports_added
918 }
919 }
920
921 fn dispatch_func_type(&self) -> (Vec<wasm_encoder::ValType>, Vec<wasm_encoder::ValType>) {
922 (
923 vec![
924 wasm_encoder::ValType::I64, // fp_lo
925 wasm_encoder::ValType::I64, // fp_hi
926 wasm_encoder::ValType::I32, // scratch_ptr
927 wasm_encoder::ValType::I32, // args_byte_len
928 wasm_encoder::ValType::I32, // results_byte_len
929 ],
930 vec![wasm_encoder::ValType::I32],
931 )
932 }
933
934 fn alloc_func_type(&self) -> (Vec<wasm_encoder::ValType>, Vec<wasm_encoder::ValType>) {
935 (
936 vec![wasm_encoder::ValType::I32],
937 vec![wasm_encoder::ValType::I32],
938 )
939 }
940
941 fn free_func_type(&self) -> (Vec<wasm_encoder::ValType>, Vec<wasm_encoder::ValType>) {
942 (
943 vec![wasm_encoder::ValType::I32, wasm_encoder::ValType::I32],
944 vec![],
945 )
946 }
947
948 fn write_import_types(&self, types: &mut wasm_encoder::TypeSection) {
949 let (p, r) = self.dispatch_func_type();
950 types.ty().function(p, r);
951 let (p, r) = self.alloc_func_type();
952 types.ty().function(p, r);
953 let (p, r) = self.free_func_type();
954 types.ty().function(p, r);
955 }
956
957 fn write_imports(&self, imports: &mut wasm_encoder::ImportSection) {
958 imports.import(
959 &self.opts.host_module,
960 &self.opts.host_fn,
961 wasm_encoder::EntityType::Function(self.dispatch_type_index),
962 );
963 imports.import(
964 &self.opts.host_module,
965 &self.opts.host_alloc_fn,
966 wasm_encoder::EntityType::Function(self.alloc_type_index),
967 );
968 imports.import(
969 &self.opts.host_module,
970 &self.opts.host_free_fn,
971 wasm_encoder::EntityType::Function(self.free_type_index),
972 );
973 }
974}
975
976impl<'a> Reencode for TensorWasmRewriter<'a> {
977 type Error = Infallible;
978
979 fn function_index(&mut self, func: u32) -> u32 {
980 self.shifted_fn_index(func)
981 }
982
983 fn parse_type_section(
984 &mut self,
985 types: &mut wasm_encoder::TypeSection,
986 section: wasmparser::TypeSectionReader<'_>,
987 ) -> Result<(), wasm_encoder::reencode::Error<Self::Error>> {
988 wasm_encoder::reencode::utils::parse_type_section(self, types, section)?;
989 self.write_import_types(types);
990 self.type_section_appended = true;
991 Ok(())
992 }
993
994 fn parse_import_section(
995 &mut self,
996 imports: &mut wasm_encoder::ImportSection,
997 section: wasmparser::ImportSectionReader<'_>,
998 ) -> Result<(), wasm_encoder::reencode::Error<Self::Error>> {
999 wasm_encoder::reencode::utils::parse_import_section(self, imports, section)?;
1000 self.write_imports(imports);
1001 self.import_section_appended = true;
1002 Ok(())
1003 }
1004
1005 fn parse_function_body(
1006 &mut self,
1007 code: &mut wasm_encoder::CodeSection,
1008 func: wasmparser::FunctionBody<'_>,
1009 ) -> Result<(), wasm_encoder::reencode::Error<Self::Error>> {
1010 let cursor = self.code_cursor;
1011 self.code_cursor += 1;
1012 let info = match self.func_infos.get(cursor) {
1013 Some(i) => i,
1014 None => {
1015 return wasm_encoder::reencode::utils::parse_function_body(self, code, func);
1016 }
1017 };
1018
1019 let should_swap =
1020 matches!(info.verdict, DetectorVerdict::Offload) && info.fingerprint.is_some();
1021 if !should_swap {
1022 return wasm_encoder::reencode::utils::parse_function_body(self, code, func);
1023 }
1024
1025 let func_ty = self
1026 .types
1027 .get(info.type_index as usize)
1028 .and_then(|t| t.as_ref());
1029 let func_ty = match func_ty {
1030 Some(t) => t,
1031 None => {
1032 return wasm_encoder::reencode::utils::parse_function_body(self, code, func);
1033 }
1034 };
1035
1036 let trampoline = match build_trampoline(
1037 info.fingerprint.expect("fingerprint set"),
1038 &self.imports,
1039 &func_ty.params,
1040 &func_ty.results,
1041 ) {
1042 Ok(t) => t,
1043 Err(_) => {
1044 // Trampoline synthesis declined. Keep the original body —
1045 // Wasmtime will execute it on the CPU path.
1046 return wasm_encoder::reencode::utils::parse_function_body(self, code, func);
1047 }
1048 };
1049
1050 code.function(&trampoline);
1051
1052 // Sanity-check parameter types declared encoder-side. We don't
1053 // push these onto the trampoline stack because they're consumed
1054 // by the byte-store loop.
1055 for p in &func_ty.params {
1056 let _ = enc_val_type(*p);
1057 }
1058
1059 self.swapped.push(OffloadedFunction {
1060 function_index: self.num_function_imports + cursor as u32,
1061 fingerprint: info.fingerprint.expect("fingerprint set"),
1062 original_op_count: info.op_count,
1063 });
1064 Ok(())
1065 }
1066
1067 fn intersperse_section_hook(
1068 &mut self,
1069 module: &mut wasm_encoder::Module,
1070 _after: Option<wasm_encoder::SectionId>,
1071 before: Option<wasm_encoder::SectionId>,
1072 ) -> Result<(), wasm_encoder::reencode::Error<Self::Error>> {
1073 // The `parse_type_section` / `parse_import_section` overrides above
1074 // already merge our additions into the original Type / Import
1075 // sections when those sections exist in the source module. The hook
1076 // below only injects fresh Type / Import sections when the module
1077 // *lacks* its own — detected by `before` skipping over Type / Import
1078 // entirely. We therefore skip the hook for `before == Type` and
1079 // `before == Import` (the parse overrides will handle them), and
1080 // skip Custom sections (which can appear anywhere and must not
1081 // trigger injection).
1082 if matches!(
1083 before,
1084 Some(wasm_encoder::SectionId::Custom)
1085 | Some(wasm_encoder::SectionId::Type)
1086 | Some(wasm_encoder::SectionId::Import)
1087 ) {
1088 return Ok(());
1089 }
1090 if !self.type_section_appended {
1091 let mut types = wasm_encoder::TypeSection::new();
1092 self.write_import_types(&mut types);
1093 module.section(&types);
1094 self.type_section_appended = true;
1095 }
1096 if !self.import_section_appended {
1097 let mut imports = wasm_encoder::ImportSection::new();
1098 self.write_imports(&mut imports);
1099 module.section(&imports);
1100 self.import_section_appended = true;
1101 }
1102 Ok(())
1103 }
1104}
1105
1106/// Re-emit the supplied Wasm with offload-candidate functions swapped for
1107/// dispatch trampolines.
1108///
1109/// On success the [`KernelCache`] is pre-populated with the PTX for every
1110/// swapped function so the runtime dispatch hits straight away.
1111pub fn rewrite_wasm(
1112 wasm: &[u8],
1113 opts: &RewriteOptions,
1114 cache: &KernelCache,
1115) -> Result<RewriteOutcome, RewriteError> {
1116 let analysis = analyse(wasm, opts, cache)?;
1117
1118 // Three new types inserted: dispatch, alloc, free.
1119 let dispatch_type_index = analysis.types.len() as u32;
1120 let alloc_type_index = dispatch_type_index + 1;
1121 let free_type_index = dispatch_type_index + 2;
1122 // Three new function imports.
1123 let imports = DispatchImports {
1124 dispatch: analysis.num_function_imports,
1125 alloc: analysis.num_function_imports + 1,
1126 free: analysis.num_function_imports + 2,
1127 };
1128 let total_defined_functions = analysis.func_infos.len() as u32;
1129
1130 let mut rewriter = TensorWasmRewriter {
1131 opts,
1132 num_function_imports: analysis.num_function_imports,
1133 dispatch_type_index,
1134 alloc_type_index,
1135 free_type_index,
1136 imports,
1137 imports_added: 3,
1138 func_infos: &analysis.func_infos,
1139 types: &analysis.types,
1140 code_cursor: 0,
1141 swapped: Vec::new(),
1142 type_section_appended: false,
1143 import_section_appended: false,
1144 };
1145
1146 let mut module = wasm_encoder::Module::new();
1147 let parser = wasmparser::Parser::new(0);
1148 Reencode::parse_core_module(&mut rewriter, &mut module, parser, wasm)?;
1149
1150 debug_assert!(
1151 rewriter.type_section_appended,
1152 "rewriter failed to append dispatch type"
1153 );
1154 debug_assert!(
1155 rewriter.import_section_appended,
1156 "rewriter failed to append dispatch import"
1157 );
1158
1159 let swapped = rewriter.swapped;
1160 Ok(RewriteOutcome {
1161 rewritten_wasm: module.finish(),
1162 offloaded_functions: swapped,
1163 total_defined_functions,
1164 })
1165}
1166
1167#[cfg(test)]
1168mod tests {
1169 use super::*;
1170
1171 /// A module with one v128-heavy function. The body uses pre-loaded v128
1172 /// locals as operands so `op_to_detector_op` walks v128 arithmetic ops
1173 /// (which it maps to `Op::V128Add`/`Op::V128Mul`) without the
1174 /// noise of `v128.const` / `drop` ops (which currently fall through to
1175 /// `Op::Other`).
1176 const V128_HEAVY_WAT: &str = r#"
1177 (module
1178 (memory 1)
1179 (func (export "hot") (result i32)
1180 (local $v v128)
1181 (loop $L
1182 (local.set $v (i32x4.add (local.get $v) (local.get $v)))
1183 (local.set $v (i32x4.add (local.get $v) (local.get $v)))
1184 (local.set $v (i32x4.add (local.get $v) (local.get $v)))
1185 (local.set $v (i32x4.add (local.get $v) (local.get $v)))
1186 (local.set $v (i32x4.add (local.get $v) (local.get $v)))
1187 (local.set $v (i32x4.add (local.get $v) (local.get $v)))
1188 (local.set $v (i32x4.add (local.get $v) (local.get $v)))
1189 (local.set $v (i32x4.add (local.get $v) (local.get $v)))
1190 (local.set $v (i32x4.add (local.get $v) (local.get $v)))
1191 (local.set $v (i32x4.add (local.get $v) (local.get $v)))
1192 (local.set $v (i32x4.add (local.get $v) (local.get $v)))
1193 (local.set $v (i32x4.add (local.get $v) (local.get $v)))
1194 (local.set $v (i32x4.add (local.get $v) (local.get $v)))
1195 (local.set $v (i32x4.add (local.get $v) (local.get $v)))
1196 (local.set $v (i32x4.add (local.get $v) (local.get $v)))
1197 (local.set $v (i32x4.add (local.get $v) (local.get $v)))
1198 )
1199 (i32.const 0)
1200 )
1201 )
1202 "#;
1203
1204 /// A trivial module with a single noop function — should NOT be swapped.
1205 const NOOP_WAT: &str = r#"(module (func (export "noop")))"#;
1206
1207 /// jit CRITICAL fix (finding 1): each SIMD opcode maps to a SIMD op
1208 /// carrying its true element type and lane count; widths the emitter
1209 /// cannot lower end-to-end fail closed to `Op::Other` (kept on CPU).
1210 #[test]
1211 fn op_to_detector_op_threads_element_type_and_fails_closed() {
1212 use wasmparser::Operator;
1213 // f32x4 / i32x4 are emittable → carry their element type + lanes.
1214 assert_eq!(
1215 op_to_detector_op(&Operator::F32x4Add),
1216 Op::V128Add {
1217 lane_ty: ElemType::F32,
1218 lanes: 4
1219 }
1220 );
1221 assert_eq!(
1222 op_to_detector_op(&Operator::I32x4Add),
1223 Op::V128Add {
1224 lane_ty: ElemType::I32,
1225 lanes: 4
1226 }
1227 );
1228 assert_eq!(
1229 op_to_detector_op(&Operator::I32x4Mul),
1230 Op::V128Mul {
1231 lane_ty: ElemType::I32,
1232 lanes: 4
1233 }
1234 );
1235 // i32x4.add is NOT classified as a float op — the headline fix.
1236 assert_ne!(
1237 op_to_detector_op(&Operator::I32x4Add),
1238 Op::V128Add {
1239 lane_ty: ElemType::F32,
1240 lanes: 4
1241 }
1242 );
1243 // FAIL CLOSED: widths the emitter cannot lower coherently stay on
1244 // the CPU path (Op::Other), never miscompiled to an f32 kernel.
1245 for op in [
1246 Operator::F64x2Add,
1247 Operator::I64x2Add,
1248 Operator::I16x8Add,
1249 Operator::I8x16Add,
1250 Operator::F64x2Mul,
1251 Operator::I16x8Mul,
1252 ] {
1253 assert_eq!(
1254 op_to_detector_op(&op),
1255 Op::Other,
1256 "non-emittable SIMD width must fail closed to Op::Other, got a SIMD op"
1257 );
1258 }
1259 }
1260
1261 /// jit LOW fix (finding 8): `return_call_indirect` classifies as a call,
1262 /// not `Op::Other`.
1263 #[test]
1264 fn op_to_detector_op_return_call_indirect_is_a_call() {
1265 use wasmparser::Operator;
1266 assert_eq!(
1267 op_to_detector_op(&Operator::ReturnCallIndirect {
1268 type_index: 0,
1269 table_index: 0,
1270 }),
1271 Op::Call
1272 );
1273 // The other call forms still classify as calls too.
1274 assert_eq!(
1275 op_to_detector_op(&Operator::ReturnCall { function_index: 0 }),
1276 Op::Call
1277 );
1278 }
1279
1280 fn noop_module_swaps_nothing_inner() {
1281 let wasm = wat::parse_str(NOOP_WAT).unwrap();
1282 let cache = KernelCache::new();
1283 let out = rewrite_wasm(&wasm, &RewriteOptions::default(), &cache).expect("rewrite");
1284 assert!(out.offloaded_functions.is_empty());
1285 assert!(cache.is_empty());
1286 let mut saw_import = false;
1287 for p in wasmparser::Parser::new(0).parse_all(&out.rewritten_wasm) {
1288 if let wasmparser::Payload::ImportSection(reader) = p.expect("rewritten payload parses")
1289 {
1290 for imp in reader {
1291 let imp = imp.expect("import parses");
1292 if imp.module == DEFAULT_HOST_MODULE && imp.name == DEFAULT_HOST_FN {
1293 saw_import = true;
1294 }
1295 }
1296 }
1297 }
1298 assert!(
1299 saw_import,
1300 "rewritten module is missing the dispatch import"
1301 );
1302 }
1303
1304 #[test]
1305 fn noop_module_swaps_nothing_and_still_adds_dispatch_import() {
1306 noop_module_swaps_nothing_inner();
1307 }
1308
1309 #[test]
1310 fn v128_heavy_module_swaps_function_and_inserts_call() {
1311 let wasm = wat::parse_str(V128_HEAVY_WAT).unwrap();
1312 let cache = KernelCache::new();
1313 let opts = RewriteOptions {
1314 detector: DetectorConfig {
1315 v128_ratio_threshold: 0.05,
1316 min_trip_count: 64,
1317 },
1318 ..RewriteOptions::default()
1319 };
1320 let out = rewrite_wasm(&wasm, &opts, &cache).expect("rewrite");
1321 assert_eq!(
1322 out.offloaded_functions.len(),
1323 1,
1324 "the v128-heavy module should produce exactly one swap"
1325 );
1326 let swapped = &out.offloaded_functions[0];
1327 assert_eq!(swapped.function_index, 0);
1328 // Rewriter pre-populates under the placeholder `TenantId(0)`; see the
1329 // `key = CacheKey::for_tenant(TenantId(0), ...)` site above.
1330 let key = CacheKey::for_tenant(TenantId(0), swapped.fingerprint, DEFAULT_SM_VERSION);
1331 assert!(cache.get(&key).is_some(), "kernel was pre-populated");
1332
1333 // The rewritten module must validate.
1334 wasmparser::Validator::new()
1335 .validate_all(&out.rewritten_wasm)
1336 .expect("rewritten wasm must validate");
1337 }
1338
1339 #[test]
1340 fn rewrite_options_default_values_match_constants() {
1341 let opts = RewriteOptions::default();
1342 assert_eq!(opts.host_module, DEFAULT_HOST_MODULE);
1343 assert_eq!(opts.host_fn, DEFAULT_HOST_FN);
1344 assert_eq!(opts.host_alloc_fn, DEFAULT_HOST_ALLOC_FN);
1345 assert_eq!(opts.host_free_fn, DEFAULT_HOST_FREE_FN);
1346 assert_eq!(opts.sm_version, DEFAULT_SM_VERSION);
1347 // Back-compat default: pre-population lands under the placeholder
1348 // tenant unless the caller plumbs the real one through.
1349 assert_eq!(opts.tenant_id, TenantId(0));
1350 }
1351
1352 /// Pre-population must key the cache under the tenant configured in
1353 /// `RewriteOptions::tenant_id` so the first runtime dispatch (which
1354 /// looks up under the owning tenant) hits the pre-populated entry
1355 /// instead of missing and re-emitting. Configure a non-zero tenant and
1356 /// assert the cache key tenant matches it — and that the historical
1357 /// `TenantId(0)` placeholder is now a miss.
1358 #[test]
1359 fn rewrite_prepop_uses_configured_tenant_id() {
1360 let wasm = wat::parse_str(V128_HEAVY_WAT).unwrap();
1361 let cache = KernelCache::new();
1362 let tenant = TenantId(4242);
1363 let opts = RewriteOptions {
1364 tenant_id: tenant,
1365 detector: DetectorConfig {
1366 v128_ratio_threshold: 0.05,
1367 min_trip_count: 64,
1368 },
1369 ..RewriteOptions::default()
1370 };
1371 let out = rewrite_wasm(&wasm, &opts, &cache).expect("rewrite");
1372 assert_eq!(
1373 out.offloaded_functions.len(),
1374 1,
1375 "the v128-heavy module should produce exactly one swap"
1376 );
1377 let fp = out.offloaded_functions[0].fingerprint;
1378
1379 // The entry is reachable under the configured tenant…
1380 let configured_key = CacheKey::for_tenant(tenant, fp, DEFAULT_SM_VERSION);
1381 assert!(
1382 cache.get(&configured_key).is_some(),
1383 "pre-populated kernel must be keyed under the configured tenant id"
1384 );
1385 // …and NOT under the old `TenantId(0)` placeholder.
1386 let placeholder_key = CacheKey::for_tenant(TenantId(0), fp, DEFAULT_SM_VERSION);
1387 assert!(
1388 cache.get(&placeholder_key).is_none(),
1389 "with a non-zero tenant configured, the placeholder TenantId(0) \
1390 key must be a miss — keys are tenant-scoped"
1391 );
1392 }
1393
1394 #[test]
1395 fn rewrite_outcome_reports_total_defined_functions() {
1396 let wat = r#"
1397 (module
1398 (func (export "a"))
1399 (func (export "b"))
1400 )
1401 "#;
1402 let wasm = wat::parse_str(wat).unwrap();
1403 let cache = KernelCache::new();
1404 let out = rewrite_wasm(&wasm, &RewriteOptions::default(), &cache).expect("rewrite");
1405 assert_eq!(out.total_defined_functions, 2);
1406 }
1407
1408 #[test]
1409 fn invalid_wasm_returns_parse_error() {
1410 let bytes = [0x00, 0x61, 0x73, 0x6d, 0xff, 0xff, 0xff, 0xff];
1411 let cache = KernelCache::new();
1412 let err = rewrite_wasm(&bytes, &RewriteOptions::default(), &cache).unwrap_err();
1413 assert!(matches!(
1414 err,
1415 RewriteError::Parse(_) | RewriteError::Reencode(_)
1416 ));
1417 }
1418
1419 #[test]
1420 fn build_trampoline_round_trip_validates() {
1421 // Build a (i32, i32) -> i32 trampoline and validate the byte
1422 // length grows monotonically with parameter count.
1423 let imports = DispatchImports {
1424 dispatch: 0,
1425 alloc: 1,
1426 free: 2,
1427 };
1428 let small = build_trampoline(0xCAFEBABE, &imports, &[], &[]).unwrap();
1429 let mid = build_trampoline(
1430 0xCAFEBABE,
1431 &imports,
1432 &[wasmparser::ValType::I32, wasmparser::ValType::I32],
1433 &[wasmparser::ValType::I32],
1434 )
1435 .unwrap();
1436 let big = build_trampoline(
1437 0xCAFEBABE,
1438 &imports,
1439 &[
1440 wasmparser::ValType::I32,
1441 wasmparser::ValType::I64,
1442 wasmparser::ValType::F32,
1443 wasmparser::ValType::F64,
1444 ],
1445 &[wasmparser::ValType::I32, wasmparser::ValType::F64],
1446 )
1447 .unwrap();
1448 assert!(small.byte_len() < mid.byte_len());
1449 assert!(mid.byte_len() < big.byte_len());
1450 }
1451
1452 #[test]
1453 fn build_trampoline_refuses_v128() {
1454 let imports = DispatchImports {
1455 dispatch: 0,
1456 alloc: 1,
1457 free: 2,
1458 };
1459 let err = build_trampoline(0, &imports, &[wasmparser::ValType::V128], &[])
1460 .expect_err("must refuse v128 param");
1461 assert!(matches!(err, RewriteError::Trampoline(_)));
1462 }
1463
1464 /// jit LOW fix (finding 9): a trampoline whose packed scratch region is
1465 /// comfortably within `i32::MAX` builds normally — the new size guard
1466 /// must not over-reject legitimate signatures. (The over-`i32::MAX`
1467 /// path is not directly reachable through `build_trampoline` because it
1468 /// would require a single function signature totalling >2 GiB of packed
1469 /// scalar arguments, which exceeds Wasm's parameter-count limits; the
1470 /// guard exists as defence-in-depth against the `as i32` truncation of
1471 /// `scratch_size` / `total_bytes` at the `i32.const` emission sites.)
1472 #[test]
1473 fn build_trampoline_within_i32_max_builds() {
1474 let imports = DispatchImports {
1475 dispatch: 0,
1476 alloc: 1,
1477 free: 2,
1478 };
1479 // A wide-but-legal signature: many 8-byte params. Total scratch is
1480 // tiny relative to i32::MAX, so the guard passes.
1481 let params = vec![wasmparser::ValType::I64; 32];
1482 let results = vec![wasmparser::ValType::F64; 8];
1483 let f = build_trampoline(0xABCD, &imports, ¶ms, &results)
1484 .expect("within-i32::MAX trampoline must build");
1485 assert!(f.byte_len() > 0);
1486 }
1487
1488 #[test]
1489 fn rewritten_module_passes_wasm_validator() {
1490 // Round-trip a few different module shapes through the rewriter
1491 // and check `wasmparser::Validator` is happy with each output.
1492 let modules = [
1493 r#"(module (func (export "noop")))"#,
1494 r#"(module
1495 (memory 1)
1496 (func (export "add") (param i32 i32) (result i32)
1497 (i32.add (local.get 0) (local.get 1))))"#,
1498 r#"(module
1499 (memory 1)
1500 (func (export "f") (param f32) (result f32)
1501 (f32.add (local.get 0) (local.get 0))))"#,
1502 ];
1503 for (i, wat_src) in modules.iter().enumerate() {
1504 let wasm = wat::parse_str(wat_src).unwrap_or_else(|e| panic!("wat #{i}: {e}"));
1505 let cache = KernelCache::new();
1506 let out = rewrite_wasm(&wasm, &RewriteOptions::default(), &cache)
1507 .unwrap_or_else(|e| panic!("rewrite #{i}: {e}"));
1508 wasmparser::Validator::new()
1509 .validate_all(&out.rewritten_wasm)
1510 .unwrap_or_else(|e| panic!("validation #{i}: {e}"));
1511 }
1512 }
1513
1514 #[test]
1515 fn pack_layout_natural_alignment() {
1516 let layout = pack_layout(&[
1517 wasmparser::ValType::I32,
1518 wasmparser::ValType::I64,
1519 wasmparser::ValType::F32,
1520 wasmparser::ValType::F64,
1521 ])
1522 .expect("supported types pack cleanly");
1523 // i32 at 0 (4b), i64 at 8 (aligned) (8b), f32 at 16 (4b), f64 at 24 (aligned) (8b)
1524 // total naive = 32, rounded to 8: 32.
1525 assert_eq!(layout.offsets, vec![0, 8, 16, 24]);
1526 assert_eq!(layout.total_bytes, 32);
1527 }
1528
1529 #[test]
1530 fn pack_layout_empty_is_zero() {
1531 let layout = pack_layout(&[]).expect("empty layout is trivially Ok");
1532 assert!(layout.offsets.is_empty());
1533 assert_eq!(layout.total_bytes, 0);
1534 }
1535
1536 /// `aligned_advance` must reject offsets that would overflow `u32` rather
1537 /// than silently wrap. We pick an offset within 7 bytes of `u32::MAX` and
1538 /// pair it with an 8-byte type so the alignment mask (7) tips the
1539 /// `checked_add` past the boundary.
1540 #[test]
1541 fn aligned_advance_overflows_cleanly() {
1542 let err = aligned_advance(u32::MAX - 3, wasmparser::ValType::I64)
1543 .expect_err("near-MAX offset must trip overflow guard");
1544 assert!(
1545 matches!(err, RewriteError::Trampoline(ref msg) if msg.contains("aligned_advance")),
1546 "expected Trampoline overflow error, got {err:?}"
1547 );
1548 }
1549
1550 /// `pack_layout`'s final 8-byte round-up uses `off.checked_add(7)`; pick
1551 /// a one-element layout whose cursor lands within 7 of `u32::MAX` so the
1552 /// round-up overflows.
1553 #[test]
1554 fn pack_layout_total_bytes_overflow() {
1555 // We can't easily push the cursor near u32::MAX through ordinary
1556 // pack_layout calls (each step bumps `off` by 4 or 8), but we can
1557 // reach into the same code path by calling `aligned_advance` for
1558 // the cursor advance and inspecting `pack_layout` indirectly.
1559 // The simplest end-to-end probe: aligned_advance to u32::MAX-3
1560 // for an i64 already overflows — see the dedicated test above.
1561 // For pack_layout's total_bytes round-up specifically, construct
1562 // an i32 entry at a near-MAX starting offset via aligned_advance:
1563 // pack_layout itself starts at 0, so to exercise the round-up
1564 // overflow we need a different vector. The honest probe is a
1565 // single-element layout with a type whose size + start aligns to
1566 // u32::MAX - 6 territory; since pack_layout starts at 0, this
1567 // can't be tripped from the public surface. We therefore assert
1568 // the round-up logic indirectly via aligned_advance overflow as
1569 // a guard on the same arithmetic. See aligned_advance test.
1570 let err = aligned_advance(u32::MAX, wasmparser::ValType::I32)
1571 .expect_err("u32::MAX cannot round up to a 4-byte boundary");
1572 assert!(
1573 matches!(err, RewriteError::Trampoline(_)),
1574 "expected Trampoline overflow error, got {err:?}"
1575 );
1576 }
1577
1578 /// `val_type_size` is fallible: feeding it `V128` (rejected by
1579 /// `is_supported_primitive`) must return `Err` rather than panic.
1580 #[test]
1581 fn val_type_size_is_fallible() {
1582 let err = val_type_size(wasmparser::ValType::V128)
1583 .expect_err("v128 is not a supported primitive");
1584 assert!(
1585 matches!(err, RewriteError::Trampoline(ref msg) if msg.contains("val_type_size")),
1586 "expected Trampoline error, got {err:?}"
1587 );
1588 }
1589
1590 /// The rewritten trampoline must trap on a nonzero dispatch return code
1591 /// rather than silently feed zero-filled results back to the caller, AND
1592 /// it must free the scratch arena on the trap path (otherwise a deopt
1593 /// storm leaks scratch slots — see M1). We can't run the rewritten module
1594 /// without a host harness from inside this unit test, so assert
1595 /// structurally: the emitted function bytes must contain the
1596 /// `local.tee $rc; i32.const 0; i32.ne; if { local.get $scratch;
1597 /// i32.const <size>; call $free; unreachable } end` opcode sequence we
1598 /// wired in after the dispatch call. This catches regressions back to
1599 /// both the old silent-drop pattern and the leak-on-trap pattern.
1600 #[test]
1601 fn trampoline_traps_on_nonzero_dispatch() {
1602 let imports = DispatchImports {
1603 dispatch: 0,
1604 alloc: 1,
1605 free: 2,
1606 };
1607 // One param so the rc local lands at index 2 (params.len() + 1 = 2)
1608 // which encodes as a single 0x02 byte in unsigned LEB128. The
1609 // scratch_ptr local is index 1 (params.len() = 1).
1610 let func = build_trampoline(
1611 0xDEAD_BEEF,
1612 &imports,
1613 &[wasmparser::ValType::I32],
1614 &[wasmparser::ValType::I32],
1615 )
1616 .expect("trampoline builds");
1617 let mut code = wasm_encoder::CodeSection::new();
1618 code.function(&func);
1619 let mut out = Vec::new();
1620 wasm_encoder::Encode::encode(&code, &mut out);
1621 // Prefix of the trap branch, up to and including the `if` opener:
1622 // 0x22 0x02 — local.tee 2 (the rc local)
1623 // 0x41 0x00 — i32.const 0
1624 // 0x47 — i32.ne
1625 // 0x04 0x40 — if (block type = empty)
1626 // 0x20 0x01 — local.get 1 (scratch_ptr) — first instr of the body
1627 const PREFIX: &[u8] = &[0x22, 0x02, 0x41, 0x00, 0x47, 0x04, 0x40, 0x20, 0x01];
1628 // The scratch_size `i32.const` in between is variable-length LEB128,
1629 // so we don't pin its bytes. The tail of the trap branch must be:
1630 // 0x10 0x02 — call 2 (the free import) — MUST precede `unreachable`
1631 // 0x00 — unreachable
1632 // 0x0B — end (closes the if)
1633 const SUFFIX: &[u8] = &[0x10, 0x02, 0x00, 0x0B];
1634 let prefix_at = out
1635 .windows(PREFIX.len())
1636 .position(|w| w == PREFIX)
1637 .unwrap_or_else(|| {
1638 panic!(
1639 "trampoline missing the trap-branch prefix \
1640 (local.tee rc; i32.const 0; i32.ne; if; local.get scratch); \
1641 body bytes: {out:02x?}"
1642 )
1643 });
1644 // The `call $free; unreachable; end` suffix must appear AFTER the
1645 // prefix — i.e. the free call sits inside the trap branch and
1646 // precedes the `unreachable`, reclaiming scratch on the trap path.
1647 let found_free_before_trap = out[prefix_at..].windows(SUFFIX.len()).any(|w| w == SUFFIX);
1648 assert!(
1649 found_free_before_trap,
1650 "trampoline missing `call $free; unreachable; end` in the trap \
1651 branch — scratch leaks on the dispatch-failure trap path; \
1652 body bytes: {out:02x?}"
1653 );
1654 }
1655}