Skip to main content

tensor_wasm_jit/
detector.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Craton Software Company
3//! GPU-offload-candidate detector.
4//!
5//! Walks a [`BlockIR`] (a simplified, in-house representation modelled after
6//! Cranelift CLIF basic blocks) and decides whether the block should be
7//! lowered to PTX. The decision rule, per the plan, is:
8//!
9//! > Flag a block if **>80 %** of its instructions are v128 SIMD ops AND the
10//! > loop trip count is statically known.
11//!
12//! The simplified IR keeps this crate independent of the Cranelift runtime
13//! API surface — the real-CLIF integration is documented in
14//! `docs/WASMTIME-FORK.md` and lands in a follow-up session once the
15//! Wasmtime team upstreams the necessary hooks.
16
17use std::fmt;
18
19use crate::ir::ElemType;
20
21/// Instruction kinds the detector recognises.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum Op {
24    /// 128-bit SIMD add. Carries the per-lane element type and the true
25    /// lane count decoded from the WASM opcode.
26    ///
27    /// jit CRITICAL fix: previously `F32x4Add | I32x4Add | I16x8Add | …`
28    /// all collapsed onto a bare `V128Add` that lost both the element type
29    /// and the real lane count, and the downstream emitter unconditionally
30    /// emitted `add.f32` — silently miscompiling integer / f64 SIMD. The
31    /// element type and lane count now travel from the opcode through to the
32    /// PTX emitter.
33    V128Add {
34        /// Per-lane element type.
35        lane_ty: ElemType,
36        /// Number of lanes (4 for `*x4`, 2 for `*x2`, 8 for `*x8`, …).
37        lanes: u32,
38    },
39    /// 128-bit SIMD multiply (carries element type + lane count, as `V128Add`).
40    V128Mul {
41        /// Per-lane element type.
42        lane_ty: ElemType,
43        /// Number of lanes.
44        lanes: u32,
45    },
46    /// 128-bit SIMD fused-multiply-add (carries element type + lane count).
47    V128Fma {
48        /// Per-lane element type.
49        lane_ty: ElemType,
50        /// Number of lanes.
51        lanes: u32,
52    },
53    /// Scalar arithmetic add.
54    ScalarAdd,
55    /// Scalar arithmetic multiply.
56    ScalarMul,
57    /// Load from linear memory.
58    Load,
59    /// Store to linear memory.
60    Store,
61    /// Conditional branch.
62    Branch,
63    /// Function call (including host imports).
64    Call,
65    /// Any operator the detector does not have a more specific bucket for
66    /// (e.g. `local.get`, `i32.const`, `drop`). Counted in the denominator of
67    /// the v128 ratio but never in the numerator. Replaces the prior
68    /// "everything-else falls through to `ScalarAdd`" behaviour that biased
69    /// non-arithmetic functions toward looking arithmetic.
70    Other,
71}
72
73impl Op {
74    /// True if this op operates on `v128` SIMD values.
75    pub fn is_v128(self) -> bool {
76        matches!(
77            self,
78            Op::V128Add { .. } | Op::V128Mul { .. } | Op::V128Fma { .. }
79        )
80    }
81}
82
83impl fmt::Display for Op {
84    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85        match self {
86            Op::V128Add { lane_ty, lanes } => return write!(f, "v128.add.{lane_ty}x{lanes}"),
87            Op::V128Mul { lane_ty, lanes } => return write!(f, "v128.mul.{lane_ty}x{lanes}"),
88            Op::V128Fma { lane_ty, lanes } => return write!(f, "v128.fma.{lane_ty}x{lanes}"),
89            _ => {}
90        }
91        let s = match self {
92            Op::ScalarAdd => "scalar.add",
93            Op::ScalarMul => "scalar.mul",
94            Op::Load => "load",
95            Op::Store => "store",
96            Op::Branch => "branch",
97            Op::Call => "call",
98            Op::Other => "other",
99            Op::V128Add { .. } | Op::V128Mul { .. } | Op::V128Fma { .. } => unreachable!(),
100        };
101        f.write_str(s)
102    }
103}
104
105/// A simplified Cranelift-style basic block.
106#[derive(Debug, Clone)]
107pub struct BlockIR {
108    /// Linear sequence of ops in this block.
109    pub ops: Vec<Op>,
110    /// Static loop trip count (if the loop in which this block sits has
111    /// a statically-known iteration count). `None` if unknown/dynamic.
112    pub loop_trip_count: Option<u64>,
113    /// Human-readable name (used in tests and trace output).
114    pub name: String,
115}
116
117impl BlockIR {
118    /// Construct a new block.
119    pub fn new(name: impl Into<String>, ops: Vec<Op>, loop_trip_count: Option<u64>) -> Self {
120        Self {
121            name: name.into(),
122            ops,
123            loop_trip_count,
124        }
125    }
126
127    /// Fraction of the block that is v128 ops (between 0.0 and 1.0).
128    pub fn v128_ratio(&self) -> f32 {
129        v128_ratio_of(&self.ops)
130    }
131}
132
133/// Annotation attached to a [`BlockIR`] after the detector runs.
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub enum DetectorVerdict {
136    /// Block should be GPU-offloaded.
137    Offload,
138    /// Block should stay on the CPU path.
139    KeepOnCpu,
140}
141
142/// Configurable parameters for the detector.
143#[derive(Debug, Clone, Copy)]
144pub struct DetectorConfig {
145    /// Minimum fraction of v128 ops to consider offloading (default 0.8).
146    pub v128_ratio_threshold: f32,
147    /// Minimum loop trip count to bother with offload setup overhead (default 64).
148    pub min_trip_count: u64,
149}
150
151impl Default for DetectorConfig {
152    fn default() -> Self {
153        Self {
154            v128_ratio_threshold: 0.8,
155            min_trip_count: 64,
156        }
157    }
158}
159
160/// Inspect a [`BlockIR`] and return a [`DetectorVerdict`].
161pub fn classify(block: &BlockIR, cfg: &DetectorConfig) -> DetectorVerdict {
162    classify_ops(&block.ops, block.loop_trip_count, cfg)
163}
164
165/// Classify directly from an op slice + trip count, without requiring an
166/// owned [`BlockIR`].
167///
168/// jit PERF fix (finding 11): the rewriter previously CLONED the whole
169/// `detector_ops` vector just to wrap it in a throwaway [`BlockIR`] for
170/// [`classify`], then moved the original vector into its per-function slot.
171/// Classifying over a borrowed slice removes that per-function clone.
172pub fn classify_ops(
173    ops: &[Op],
174    loop_trip_count: Option<u64>,
175    cfg: &DetectorConfig,
176) -> DetectorVerdict {
177    let ratio = v128_ratio_of(ops);
178    let trip_ok = loop_trip_count
179        .map(|n| n >= cfg.min_trip_count)
180        .unwrap_or(false);
181    if ratio >= cfg.v128_ratio_threshold && trip_ok {
182        DetectorVerdict::Offload
183    } else {
184        DetectorVerdict::KeepOnCpu
185    }
186}
187
188/// Fraction of `ops` that are v128 ops (0.0 for an empty slice). Shared by
189/// [`BlockIR::v128_ratio`] and [`classify_ops`].
190fn v128_ratio_of(ops: &[Op]) -> f32 {
191    if ops.is_empty() {
192        return 0.0;
193    }
194    let v128 = ops.iter().filter(|o| o.is_v128()).count();
195    v128 as f32 / ops.len() as f32
196}
197
198/// Convenience: classify with default config.
199pub fn classify_default(block: &BlockIR) -> DetectorVerdict {
200    classify(block, &DetectorConfig::default())
201}
202
203// ---------------------------------------------------------------------------
204// Wave-2 Pliron pipeline opt-in (W2.6)
205// ---------------------------------------------------------------------------
206//
207// The block-based detector above produces *blueprint candidates*
208// (matmul / vector_add / conv2d 3x3 — picked by `classify` returning
209// `Offload`). Wave 2 adds a second, additive candidate-producing path that
210// runs the function through the W2.4 module-level lowering driver.
211//
212// The integration contract — documented for downstream callers
213// (`tensor-wasm-exec` / `tensor-wasm-tenant`) — is:
214//
215// * The existing block-based detector keeps producing blueprint candidates;
216//   its behaviour is **unchanged**.
217// * When `cuda-oxide-backend` is enabled **and** the runtime opt-in is on,
218//   [`try_pliron_candidate`] additionally produces a [`PlironCandidate`] from
219//   a Cranelift function the blueprint detector did not recognise.
220// * The orchestration ("blueprint first, then Pliron fallback") lives in the
221//   caller. `detector.rs` only exposes both candidate-producing APIs.
222//
223// Everything below is gated on `cuda-oxide-backend`. Callers must cfg-gate
224// at the call site; we deliberately do not provide a feature-off shim
225// (wave 3 will wire the call sites and the shim would just add dead surface
226// area in the meantime).
227
228/// Runtime opt-in for the wave-2 Pliron pipeline. When true and the
229/// `cuda-oxide-backend` feature is enabled, [`Detector`](self) additionally
230/// produces Pliron-pipeline candidates from blocks the blueprint detector
231/// did NOT recognize. When false (the default), only blueprint candidates
232/// are produced.
233///
234/// Set via the `TENSOR_WASM_PLIRON_PIPELINE` environment variable (presence
235/// of any non-empty value enables it). Wave 3+ will replace this env-var
236/// with a structured runtime config.
237#[cfg(feature = "cuda-oxide-backend")]
238pub fn pliron_pipeline_enabled() -> bool {
239    std::env::var("TENSOR_WASM_PLIRON_PIPELINE")
240        .map(|v| !v.is_empty())
241        .unwrap_or(false)
242}
243
244/// A Pliron-pipeline candidate produced by the wave-2 path.
245///
246/// Distinct from the blueprint candidate produced by [`classify`] (which
247/// encodes one of the three hand-written blueprints: `matmul`,
248/// `vector_add`, `conv2d` 3x3). A `PlironCandidate` carries a
249/// [`crate::lowered_ir::LoweredFunction`] that the wave-3 PTX emitter
250/// will consume directly.
251#[cfg(feature = "cuda-oxide-backend")]
252#[derive(Debug, Clone)]
253pub struct PlironCandidate {
254    /// The lowered function ready for the wave-3 `pliron::Operation`
255    /// converter.
256    pub lowered: crate::lowered_ir::LoweredFunction,
257}
258
259/// Attempt to route a Cranelift [`cranelift_codegen::ir::Function`] through
260/// the wave-2 Pliron pipeline.
261///
262/// Returns `None` if the function is rejected by the reject-list or fails
263/// any lowering pass; the caller should fall back to the blueprint path.
264/// Returns `Some` with the lowered function when the W2.4 driver succeeds.
265///
266/// Behaviour matrix:
267///
268/// * `cuda-oxide-backend` feature disabled → the entire function is
269///   compiled out; callers must cfg-gate the call site.
270/// * Feature enabled, [`pliron_pipeline_enabled`] returns `false` → always
271///   returns `None` without running the driver.
272/// * Feature enabled, opt-in on, reject-list hit → returns `None`.
273/// * Feature enabled, opt-in on, lowering error → returns `None`
274///   (`Result::ok` discards the diagnostic; callers wanting the structured
275///   error should invoke [`crate::lowering_driver::lower_function`]
276///   directly).
277/// * Feature enabled, opt-in on, lowering succeeds → returns `Some`.
278#[cfg(feature = "cuda-oxide-backend")]
279pub fn try_pliron_candidate(func: &cranelift_codegen::ir::Function) -> Option<PlironCandidate> {
280    if !pliron_pipeline_enabled() {
281        return None;
282    }
283    // Reject-list preflight (cheap upfront check; wave-1 lowerings also
284    // surface their own per-op errors, but the reject-list catches the
285    // common "this whole function is doomed" cases without running the
286    // full driver).
287    if crate::reject_list::check_function(func).is_some() {
288        return None;
289    }
290    crate::lowering_driver::lower_function(func)
291        .map(|lowered| PlironCandidate { lowered })
292        .ok()
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    fn block(name: &str, ops: Vec<Op>, loop_n: Option<u64>) -> BlockIR {
300        BlockIR::new(name, ops, loop_n)
301    }
302
303    /// Test-helper f32x4 SIMD ops (the production default shape).
304    fn add() -> Op {
305        Op::V128Add {
306            lane_ty: ElemType::F32,
307            lanes: 4,
308        }
309    }
310    fn mul() -> Op {
311        Op::V128Mul {
312            lane_ty: ElemType::F32,
313            lanes: 4,
314        }
315    }
316    fn fma() -> Op {
317        Op::V128Fma {
318            lane_ty: ElemType::F32,
319            lanes: 4,
320        }
321    }
322
323    #[test]
324    fn mixed_v128_ratio_below_threshold_is_kept_on_cpu() {
325        let b = block(
326            "vector_add_loop",
327            vec![Op::Load, Op::Load, add(), add(), add(), add(), Op::Store],
328            Some(128),
329        );
330        // 4/7 = 57% — under threshold. Need >80%.
331        assert_eq!(classify_default(&b), DetectorVerdict::KeepOnCpu);
332    }
333
334    #[test]
335    fn high_v128_ratio_offloaded() {
336        let b = block(
337            "matmul_inner",
338            vec![
339                fma(),
340                fma(),
341                fma(),
342                fma(),
343                fma(),
344                fma(),
345                fma(),
346                fma(),
347                mul(),
348                Op::Store,
349            ],
350            Some(512),
351        );
352        // 9/10 = 90% — over threshold AND trip count 512 > 64.
353        assert_eq!(classify_default(&b), DetectorVerdict::Offload);
354    }
355
356    #[test]
357    fn pure_v128_matmul_tile_is_offloaded() {
358        let b = block(
359            "matmul_tile",
360            vec![
361                Op::Load,
362                Op::Load,
363                fma(),
364                fma(),
365                fma(),
366                fma(),
367                fma(),
368                fma(),
369                fma(),
370                fma(),
371                fma(),
372                fma(),
373                fma(),
374                fma(),
375                Op::Store,
376            ],
377            Some(256),
378        );
379        // 12/15 = 80% — meets threshold AND trip count 256 > 64.
380        assert_eq!(classify_default(&b), DetectorVerdict::Offload);
381    }
382
383    #[test]
384    fn pure_v128_vector_mul_loop_is_offloaded() {
385        let b = block(
386            "vector_mul",
387            vec![
388                Op::Load,
389                mul(),
390                mul(),
391                mul(),
392                mul(),
393                add(),
394                add(),
395                add(),
396                add(),
397                Op::Store,
398            ],
399            Some(1024),
400        );
401        // 8/10 = 80% — meets threshold AND trip count 1024 > 64.
402        assert_eq!(classify_default(&b), DetectorVerdict::Offload);
403    }
404
405    #[test]
406    fn dynamic_loop_not_offloaded_even_if_v128_heavy() {
407        let b = block(
408            "dynamic_loop",
409            vec![add(); 16],
410            None, // unknown trip count
411        );
412        assert_eq!(classify_default(&b), DetectorVerdict::KeepOnCpu);
413    }
414
415    #[test]
416    fn tiny_loop_not_offloaded() {
417        let b = block(
418            "tiny",
419            vec![add(); 16],
420            Some(8), // trip < threshold (64)
421        );
422        assert_eq!(classify_default(&b), DetectorVerdict::KeepOnCpu);
423    }
424
425    #[test]
426    fn scalar_heavy_not_offloaded() {
427        let b = block(
428            "scalar",
429            vec![
430                Op::ScalarAdd,
431                Op::ScalarAdd,
432                Op::ScalarMul,
433                Op::Branch,
434                Op::Call,
435                Op::Load,
436            ],
437            Some(1024),
438        );
439        assert_eq!(classify_default(&b), DetectorVerdict::KeepOnCpu);
440    }
441
442    #[test]
443    fn op_is_v128_taxonomy() {
444        assert!(add().is_v128());
445        assert!(mul().is_v128());
446        assert!(fma().is_v128());
447        // Element type does not change the taxonomy classification.
448        assert!(Op::V128Add {
449            lane_ty: ElemType::I32,
450            lanes: 4
451        }
452        .is_v128());
453        assert!(!Op::ScalarAdd.is_v128());
454        assert!(!Op::Load.is_v128());
455    }
456
457    #[test]
458    fn op_display_carries_element_type() {
459        assert_eq!(add().to_string(), "v128.add.f32x4");
460        assert_eq!(
461            Op::V128Mul {
462                lane_ty: ElemType::I32,
463                lanes: 4
464            }
465            .to_string(),
466            "v128.mul.i32x4"
467        );
468        assert_eq!(Op::Load.to_string(), "load");
469    }
470
471    #[test]
472    fn config_threshold_tunable() {
473        let b = block("borderline", vec![add(), add(), Op::Load], Some(128));
474        // 2/3 = 67% — below default 80% threshold.
475        assert_eq!(classify_default(&b), DetectorVerdict::KeepOnCpu);
476        // Lower the threshold to 60% — now it's offloaded.
477        let cfg = DetectorConfig {
478            v128_ratio_threshold: 0.6,
479            ..DetectorConfig::default()
480        };
481        assert_eq!(classify(&b, &cfg), DetectorVerdict::Offload);
482    }
483}
484
485// ---------------------------------------------------------------------------
486// Wave-2 Pliron pipeline tests (W2.6)
487// ---------------------------------------------------------------------------
488//
489// These tests cover the opt-in `try_pliron_candidate` path and the
490// `pliron_pipeline_enabled` env-var probe. They are gated on
491// `cuda-oxide-backend` because the production code they exercise is also
492// feature-gated.
493//
494// Env-var note: `std::env::set_var` / `remove_var` mutate process-global
495// state and are unsafe to race with other tests, so all tests touching
496// `TENSOR_WASM_PLIRON_PIPELINE` serialize through a shared mutex. Cargo
497// runs unit tests inside a single binary on multiple threads by default;
498// the mutex is the standard fix.
499#[cfg(all(test, feature = "cuda-oxide-backend"))]
500mod pliron_pipeline_tests {
501    use super::*;
502    use crate::lowering_test_support::function_with_binary_op;
503    use cranelift_codegen::ir::immediates::Offset32;
504    use cranelift_codegen::ir::instructions::InstructionData;
505    use cranelift_codegen::ir::{
506        types, AbiParam, Function, MemFlags, Opcode, Signature, UserFuncName, Value,
507    };
508    use cranelift_codegen::isa::CallConv;
509    use std::sync::Mutex;
510
511    /// Global lock for env-var manipulation. `set_var` / `remove_var` are
512    /// not thread-safe; without this lock cargo's parallel test runner
513    /// produces flaky results.
514    ///
515    /// `OnceLock` would be nicer than a `static Mutex` constructed at
516    /// link-time, but `Mutex::new` is `const` since 1.63 so this is fine.
517    static ENV_LOCK: Mutex<()> = Mutex::new(());
518
519    /// `pliron_pipeline_enabled` returns `false` when the env var is unset.
520    /// Default behaviour — the wave-2 path is opt-in, not opt-out.
521    #[test]
522    fn pliron_pipeline_disabled_by_default() {
523        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
524        std::env::remove_var("TENSOR_WASM_PLIRON_PIPELINE");
525        assert!(!pliron_pipeline_enabled());
526    }
527
528    /// `pliron_pipeline_enabled` returns `true` when the env var is set to
529    /// any non-empty value. The check is "presence of a value", not
530    /// "value == '1'" — wave 3+ will replace the env-var with a structured
531    /// runtime config, so we keep the wave-2 contract as loose as possible.
532    #[test]
533    fn pliron_pipeline_enabled_when_env_var_set() {
534        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
535        std::env::set_var("TENSOR_WASM_PLIRON_PIPELINE", "1");
536        let enabled = pliron_pipeline_enabled();
537        // Reset before asserting so a failing assert doesn't leak the
538        // env var into sibling tests in the same binary.
539        std::env::remove_var("TENSOR_WASM_PLIRON_PIPELINE");
540        assert!(enabled);
541    }
542
543    /// `try_pliron_candidate` on a simple `iadd; return` function returns
544    /// `Some` when the pipeline is enabled. Mirrors the wave-2 driver's
545    /// happy-path test from `lowering_driver::tests::lowers_single_block_iadd_return`.
546    #[test]
547    fn try_pliron_candidate_some_on_iadd_return() {
548        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
549        std::env::set_var("TENSOR_WASM_PLIRON_PIPELINE", "1");
550        let (func, _inst) = function_with_binary_op(Opcode::Iadd, types::I32);
551        let candidate = try_pliron_candidate(&func);
552        std::env::remove_var("TENSOR_WASM_PLIRON_PIPELINE");
553
554        let candidate = candidate.expect("iadd+return must lower");
555        // Sanity-check the lowered function is non-trivial: at least one
556        // block and at least the `AddI + Return` op pair.
557        assert!(!candidate.lowered.blocks.is_empty());
558        assert!(candidate.lowered.blocks[0].ops.len() >= 2);
559    }
560
561    /// `try_pliron_candidate` returns `None` on a function the reject-list
562    /// flags (here: `atomic_load`) even when the pipeline is enabled. The
563    /// reject-list preflight is what saves us from running the full driver
564    /// on a function we know is doomed.
565    #[test]
566    fn try_pliron_candidate_none_on_reject_list_hit() {
567        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
568        std::env::set_var("TENSOR_WASM_PLIRON_PIPELINE", "1");
569
570        // Build a function whose first instruction is `atomic_load` —
571        // pinned in `reject_list::classify_opcode` as
572        // `RejectReason::Atomic("atomic_load")`. The detector only looks
573        // at the opcode, so the dummy `Value(0)` arg is fine.
574        let mut sig = Signature::new(CallConv::SystemV);
575        sig.params.push(AbiParam::new(types::I32));
576        let mut func =
577            Function::with_name_signature(UserFuncName::testcase("atomic_fn".as_bytes()), sig);
578        let block = func.dfg.make_block();
579        let _param = func.dfg.append_block_param(block, types::I32);
580        func.layout.append_block(block);
581
582        let inst = func.dfg.make_inst(InstructionData::LoadNoOffset {
583            opcode: Opcode::AtomicLoad,
584            flags: MemFlags::new(),
585            arg: Value::from_u32(0),
586        });
587        func.layout.append_inst(inst, block);
588
589        let candidate = try_pliron_candidate(&func);
590        std::env::remove_var("TENSOR_WASM_PLIRON_PIPELINE");
591
592        assert!(
593            candidate.is_none(),
594            "atomic_load must be reject-listed; got Some",
595        );
596    }
597
598    /// `try_pliron_candidate` returns `None` when the pipeline is disabled,
599    /// regardless of whether the function would otherwise lower. Pins the
600    /// "off by default" contract that matters most for callers who do not
601    /// yet cfg-gate.
602    #[test]
603    fn try_pliron_candidate_none_when_disabled() {
604        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
605        std::env::remove_var("TENSOR_WASM_PLIRON_PIPELINE");
606        let (func, _inst) = function_with_binary_op(Opcode::Iadd, types::I32);
607        assert!(try_pliron_candidate(&func).is_none());
608    }
609
610    /// Sanity: keep the `Offset32` import live. Cranelift's import surface
611    /// is wide; this assertion documents that `try_pliron_candidate` is
612    /// opcode-driven (it doesn't inspect immediates), matching the
613    /// reject-list's own posture.
614    #[test]
615    fn offset_field_is_irrelevant_to_pliron_dispatch() {
616        let _ = Offset32::new(0);
617    }
618}