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}