Skip to main content

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, &params, &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}