Skip to main content

tensor_wasm_jit/
ir.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Craton Software Company
3//! `TensorWasmIR` — a normalised IR easier to lower to PTX than raw CLIF/Wasm.
4//!
5//! The IR is the contract between the detector (S10), the lowering pass
6//! (`clif_lower`, S11), the PTX emitter (`ptx_emit`, S12), and the kernel
7//! cache (S13). Keeping it small and explicit means each successor stage
8//! can be unit-tested without dragging in the others.
9
10use std::fmt;
11
12/// Element (lane) type of a SIMD vector op.
13///
14/// jit CRITICAL fix: prior to threading this through, every WASM SIMD
15/// shape (`f32x4`, `i32x4`, `f64x2`, `i16x8`, `i8x16`, …) collapsed onto a
16/// single `VecAdd`/`VecMul` carrying only `lanes`, and the emitter always
17/// emitted `add.f32` regardless of element type — silently miscompiling
18/// integer and f64 SIMD. The element type now travels from the WASM opcode
19/// (`detector::Op::V128Add { lane_ty, lanes }`) through the blueprint
20/// (`TensorWasmOp`) into the PTX emitter so the emitter can pick the
21/// correct instruction (`add.f32` / `add.s32` / `mul.lo.s32` / …) or, where
22/// a width is not yet emittable end-to-end, the candidate is failed closed
23/// to the CPU path upstream (see `rewrite::op_to_detector_op`).
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
25pub enum ElemType {
26    /// 8-bit integer lane.
27    I8,
28    /// 16-bit integer lane.
29    I16,
30    /// 32-bit integer lane.
31    I32,
32    /// 64-bit integer lane.
33    I64,
34    /// 32-bit IEEE-754 float lane.
35    F32,
36    /// 64-bit IEEE-754 float lane.
37    F64,
38}
39
40impl ElemType {
41    /// Width of one lane in bytes (used to advance the load/store offset
42    /// cursor in the emitter — a lane is `byte_width()` bytes wide, not a
43    /// hard-coded 4).
44    pub fn byte_width(self) -> u32 {
45        match self {
46            ElemType::I8 => 1,
47            ElemType::I16 => 2,
48            ElemType::I32 | ElemType::F32 => 4,
49            ElemType::I64 | ElemType::F64 => 8,
50        }
51    }
52
53    /// True if this is a floating-point lane type.
54    pub fn is_float(self) -> bool {
55        matches!(self, ElemType::F32 | ElemType::F64)
56    }
57
58    /// Stable tag byte for fingerprinting. Explicit so reordering the enum
59    /// can never silently change a cache key.
60    fn fingerprint_tag(self) -> u8 {
61        match self {
62            ElemType::I8 => 0,
63            ElemType::I16 => 1,
64            ElemType::I32 => 2,
65            ElemType::I64 => 3,
66            ElemType::F32 => 4,
67            ElemType::F64 => 5,
68        }
69    }
70}
71
72impl fmt::Display for ElemType {
73    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74        let s = match self {
75            ElemType::I8 => "i8",
76            ElemType::I16 => "i16",
77            ElemType::I32 => "i32",
78            ElemType::I64 => "i64",
79            ElemType::F32 => "f32",
80            ElemType::F64 => "f64",
81        };
82        f.write_str(s)
83    }
84}
85
86/// One coarse-grained op in a TensorWasm-emitted GPU kernel.
87///
88/// Each op corresponds to a small group of PTX instructions; the emitter
89/// (S12) materialises them into a register-allocated assembly block.
90#[derive(Debug, Clone, PartialEq)]
91pub enum TensorWasmOp {
92    /// `c = a + b` element-wise.
93    VecAdd {
94        /// Per-lane element type.
95        elem: ElemType,
96        /// Lane count.
97        lanes: u32,
98    },
99    /// `c = a * b` element-wise.
100    VecMul {
101        /// Per-lane element type.
102        elem: ElemType,
103        /// Lane count.
104        lanes: u32,
105    },
106    /// `d = a*b + c` element-wise (single rounding for floats).
107    VecFma {
108        /// Per-lane element type.
109        elem: ElemType,
110        /// Lane count.
111        lanes: u32,
112    },
113    /// 16x16x16 matrix multiply-accumulate (wmma on sm_80).
114    MatMul {
115        /// Tile size m (currently fixed at 16).
116        m: u32,
117        /// Tile size n (currently fixed at 16).
118        n: u32,
119        /// Tile size k (currently fixed at 16).
120        k: u32,
121    },
122    /// Load lanes from unified-memory pointer `+ offset`.
123    LoadUnified {
124        /// Per-lane element type.
125        elem: ElemType,
126        /// Lane count.
127        lanes: u32,
128    },
129    /// Store lanes to unified-memory pointer `+ offset`.
130    StoreUnified {
131        /// Per-lane element type.
132        elem: ElemType,
133        /// Lane count.
134        lanes: u32,
135    },
136    /// CTA-wide synchronisation barrier.
137    Barrier,
138}
139
140impl fmt::Display for TensorWasmOp {
141    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
142        match self {
143            TensorWasmOp::VecAdd { elem, lanes } => write!(f, "vec_add.{elem}[{lanes}]"),
144            TensorWasmOp::VecMul { elem, lanes } => write!(f, "vec_mul.{elem}[{lanes}]"),
145            TensorWasmOp::VecFma { elem, lanes } => write!(f, "vec_fma.{elem}[{lanes}]"),
146            TensorWasmOp::MatMul { m, n, k } => write!(f, "matmul[{m}x{n}x{k}]"),
147            TensorWasmOp::LoadUnified { elem, lanes } => write!(f, "load_unified.{elem}[{lanes}]"),
148            TensorWasmOp::StoreUnified { elem, lanes } => {
149                write!(f, "store_unified.{elem}[{lanes}]")
150            }
151            TensorWasmOp::Barrier => f.write_str("barrier"),
152        }
153    }
154}
155
156/// Hint about CUDA launch geometry. Provided by the lowering pass when it
157/// can infer a sensible grid/block from the source loop's trip count.
158#[derive(Debug, Clone, Copy, PartialEq, Eq)]
159pub struct GridHint {
160    /// Total number of threads to launch (across all blocks).
161    pub total_threads: u32,
162    /// Preferred CTA size; the emitter rounds the total to a multiple.
163    pub preferred_block_size: u32,
164}
165
166impl Default for GridHint {
167    fn default() -> Self {
168        Self {
169            total_threads: 256,
170            preferred_block_size: 128,
171        }
172    }
173}
174
175impl GridHint {
176    /// Compute the corresponding (grid_size, block_size) pair.
177    pub fn launch_geometry(&self) -> (u32, u32) {
178        let block = self.preferred_block_size.max(1);
179        let grid = self.total_threads.div_ceil(block);
180        (grid.max(1), block)
181    }
182}
183
184/// A complete blueprint that the emitter (S12) can turn into PTX text.
185#[derive(Debug, Clone, PartialEq)]
186pub struct TensorWasmKernelBlueprint {
187    /// Symbolic entry name (used by `load_ptx` lookup).
188    pub entry: String,
189    /// Ordered ops to emit.
190    pub ops: Vec<TensorWasmOp>,
191    /// Grid hint (S11 fills in; S12 honours).
192    pub grid_hint: GridHint,
193    /// Bytes of shared memory the kernel will request.
194    pub shared_mem_bytes: u32,
195}
196
197impl TensorWasmKernelBlueprint {
198    /// Construct a new blueprint.
199    pub fn new(entry: impl Into<String>) -> Self {
200        Self {
201            entry: entry.into(),
202            ops: Vec::new(),
203            grid_hint: GridHint::default(),
204            shared_mem_bytes: 0,
205        }
206    }
207
208    /// Append an op; returns `self` for builder-style chaining.
209    pub fn push(mut self, op: TensorWasmOp) -> Self {
210        self.ops.push(op);
211        self
212    }
213
214    /// Update the grid hint.
215    pub fn with_grid(mut self, hint: GridHint) -> Self {
216        self.grid_hint = hint;
217        self
218    }
219
220    /// Update the shared-memory requirement.
221    pub fn with_shared_mem(mut self, bytes: u32) -> Self {
222        self.shared_mem_bytes = bytes;
223        self
224    }
225
226    /// True if the blueprint contains no ops (a no-op kernel).
227    pub fn is_empty(&self) -> bool {
228        self.ops.is_empty()
229    }
230
231    /// Canonical 256-bit BLAKE3 digest of the blueprint. Both
232    /// [`fingerprint`](Self::fingerprint) (64-bit cache key) and
233    /// [`fingerprint128`](Self::fingerprint128) (128-bit, lower collision
234    /// probability) are derived from this single digest, so they agree on
235    /// the hashed schema by construction.
236    fn digest(&self) -> [u8; 32] {
237        let mut hasher = blake3::Hasher::new();
238        // Domain-separate so changes to the hashed schema can never collide
239        // with an older fingerprint namespace — bump this on any schema
240        // change. v2 adds the per-lane element type to every lane-bearing op
241        // (jit HIGH fix): v1 hashed only `lanes`, so `i32x4.add` and
242        // `f32x4.add` collided. Rolling the tag to v2 guarantees no v1
243        // cache key is ever reused for a v2-schema blueprint.
244        hasher.update(b"tensor-wasm-jit::ir::v2\0");
245        hasher.update(&(self.entry.len() as u64).to_le_bytes());
246        hasher.update(self.entry.as_bytes());
247        hasher.update(&(self.ops.len() as u64).to_le_bytes());
248        for op in &self.ops {
249            // Variant tag (stable per-variant) followed by the variant's
250            // payload bytes. Variant tags are explicit so reordering the
251            // enum cannot accidentally change fingerprints.
252            // jit HIGH fix: every lane-bearing op now hashes its element
253            // type *before* its lane count. Previously only `lanes` was
254            // hashed, so `i32x4.add` and `f32x4.add` produced the same
255            // fingerprint and aliased to the same cache key / kernel — a
256            // cross-kernel cache-poisoning bug. The element-type tag byte is
257            // explicit (see `ElemType::fingerprint_tag`) so reordering the
258            // enum cannot silently roll a key.
259            match op {
260                TensorWasmOp::VecAdd { elem, lanes } => {
261                    hasher.update(&[0u8]);
262                    hasher.update(&[elem.fingerprint_tag()]);
263                    hasher.update(&lanes.to_le_bytes());
264                }
265                TensorWasmOp::VecMul { elem, lanes } => {
266                    hasher.update(&[1u8]);
267                    hasher.update(&[elem.fingerprint_tag()]);
268                    hasher.update(&lanes.to_le_bytes());
269                }
270                TensorWasmOp::VecFma { elem, lanes } => {
271                    hasher.update(&[2u8]);
272                    hasher.update(&[elem.fingerprint_tag()]);
273                    hasher.update(&lanes.to_le_bytes());
274                }
275                TensorWasmOp::MatMul { m, n, k } => {
276                    hasher.update(&[3u8]);
277                    hasher.update(&m.to_le_bytes());
278                    hasher.update(&n.to_le_bytes());
279                    hasher.update(&k.to_le_bytes());
280                }
281                TensorWasmOp::LoadUnified { elem, lanes } => {
282                    hasher.update(&[4u8]);
283                    hasher.update(&[elem.fingerprint_tag()]);
284                    hasher.update(&lanes.to_le_bytes());
285                }
286                TensorWasmOp::StoreUnified { elem, lanes } => {
287                    hasher.update(&[5u8]);
288                    hasher.update(&[elem.fingerprint_tag()]);
289                    hasher.update(&lanes.to_le_bytes());
290                }
291                TensorWasmOp::Barrier => {
292                    hasher.update(&[6u8]);
293                }
294            }
295        }
296        hasher.update(&self.grid_hint.total_threads.to_le_bytes());
297        hasher.update(&self.grid_hint.preferred_block_size.to_le_bytes());
298        hasher.update(&self.shared_mem_bytes.to_le_bytes());
299        *hasher.finalize().as_bytes()
300    }
301
302    /// Stable 64-bit hash used as the `KernelCache` key by S13.
303    ///
304    /// Uses `blake3` over a canonical byte serialisation so the hash is
305    /// deterministic across Rust versions and platforms — `std`'s
306    /// `DefaultHasher` (SipHash) is explicitly documented as unstable and
307    /// must not be relied on for persistence. The first 8 bytes of the 32-
308    /// byte BLAKE3 digest are interpreted as a little-endian u64.
309    ///
310    /// The width is intentionally kept at 64 bits: the on-disk cache format
311    /// (`DiskCache`, `[24..32) blueprint fingerprint (u64 LE)`), the
312    /// [`crate::deopt::DeoptGuard`] `DashMap<u64, _>` key, and the
313    /// cross-crate `OffloadedFunction.fingerprint` ABI all key on this u64.
314    /// Callers wanting a lower collision probability without those
315    /// persistence/ABI constraints can use [`fingerprint128`](Self::fingerprint128).
316    pub fn fingerprint(&self) -> u64 {
317        let bytes = self.digest();
318        u64::from_le_bytes([
319            bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
320        ])
321    }
322
323    /// 128-bit widening of [`fingerprint`](Self::fingerprint), derived from
324    /// the same canonical BLAKE3 digest.
325    ///
326    /// jit HIGH (finding 2, truncation width): a 64-bit truncation of a
327    /// 256-bit digest has a birthday-bound collision probability around
328    /// 2^-32 across ~4 billion distinct kernels. Embedders that key kernels
329    /// in a context not bound by the 64-bit on-disk/ABI format (e.g. an
330    /// in-memory dedup map over a very large kernel population) should key
331    /// on this 128-bit value instead, which pushes the birthday bound out
332    /// to ~2^-64. The low 64 bits equal [`fingerprint`](Self::fingerprint).
333    pub fn fingerprint128(&self) -> u128 {
334        let bytes = self.digest();
335        let mut buf = [0u8; 16];
336        buf.copy_from_slice(&bytes[0..16]);
337        u128::from_le_bytes(buf)
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    #[test]
346    fn blueprint_builder() {
347        let bp = TensorWasmKernelBlueprint::new("vector_add")
348            .push(TensorWasmOp::LoadUnified {
349                elem: ElemType::F32,
350                lanes: 4,
351            })
352            .push(TensorWasmOp::VecAdd {
353                elem: ElemType::F32,
354                lanes: 4,
355            })
356            .push(TensorWasmOp::StoreUnified {
357                elem: ElemType::F32,
358                lanes: 4,
359            })
360            .with_grid(GridHint {
361                total_threads: 1024,
362                preferred_block_size: 128,
363            })
364            .with_shared_mem(0);
365        assert_eq!(bp.entry, "vector_add");
366        assert_eq!(bp.ops.len(), 3);
367        assert_eq!(bp.grid_hint.total_threads, 1024);
368    }
369
370    #[test]
371    fn grid_geometry_rounds_up() {
372        let g = GridHint {
373            total_threads: 130,
374            preferred_block_size: 64,
375        };
376        let (grid, block) = g.launch_geometry();
377        assert_eq!(block, 64);
378        assert_eq!(grid, 3); // ceil(130 / 64)
379    }
380
381    #[test]
382    fn fingerprint_is_deterministic() {
383        let a = TensorWasmKernelBlueprint::new("k").push(TensorWasmOp::VecAdd {
384            elem: ElemType::F32,
385            lanes: 4,
386        });
387        let b = TensorWasmKernelBlueprint::new("k").push(TensorWasmOp::VecAdd {
388            elem: ElemType::F32,
389            lanes: 4,
390        });
391        assert_eq!(a.fingerprint(), b.fingerprint());
392        assert_eq!(a.fingerprint128(), b.fingerprint128());
393        // The 64-bit key is the low half of the 128-bit fingerprint.
394        assert_eq!(
395            a.fingerprint() as u128,
396            a.fingerprint128() & u64::MAX as u128
397        );
398    }
399
400    #[test]
401    fn fingerprint_changes_with_lanes() {
402        let a = TensorWasmKernelBlueprint::new("k").push(TensorWasmOp::VecAdd {
403            elem: ElemType::F32,
404            lanes: 4,
405        });
406        let b = TensorWasmKernelBlueprint::new("k").push(TensorWasmOp::VecAdd {
407            elem: ElemType::F32,
408            lanes: 8,
409        });
410        assert_ne!(a.fingerprint(), b.fingerprint());
411    }
412
413    /// jit HIGH (finding 2): two SIMD ops that differ ONLY in element type
414    /// must produce distinct fingerprints. Before the fix both collapsed to
415    /// `VecAdd { lanes }` and aliased to the same cache key, so an
416    /// `i32x4.add` kernel could be served from an `f32x4.add` cache slot.
417    #[test]
418    fn fingerprint_distinguishes_element_type() {
419        let f32_add = TensorWasmKernelBlueprint::new("k").push(TensorWasmOp::VecAdd {
420            elem: ElemType::F32,
421            lanes: 4,
422        });
423        let i32_add = TensorWasmKernelBlueprint::new("k").push(TensorWasmOp::VecAdd {
424            elem: ElemType::I32,
425            lanes: 4,
426        });
427        assert_ne!(
428            f32_add.fingerprint(),
429            i32_add.fingerprint(),
430            "f32x4.add and i32x4.add must not share a 64-bit cache key"
431        );
432        assert_ne!(
433            f32_add.fingerprint128(),
434            i32_add.fingerprint128(),
435            "f32x4.add and i32x4.add must not share a 128-bit fingerprint"
436        );
437        // And across every lane-bearing op family.
438        let f32_load = TensorWasmKernelBlueprint::new("k").push(TensorWasmOp::LoadUnified {
439            elem: ElemType::F32,
440            lanes: 4,
441        });
442        let i32_load = TensorWasmKernelBlueprint::new("k").push(TensorWasmOp::LoadUnified {
443            elem: ElemType::I32,
444            lanes: 4,
445        });
446        assert_ne!(f32_load.fingerprint(), i32_load.fingerprint());
447    }
448
449    #[test]
450    fn fingerprint_is_blake3_based_and_pinned() {
451        // Pin a small known case. If this changes, bump the `tensor-wasm-jit::ir::vN`
452        // tag in `fingerprint()` so the cache key namespace is also rolled.
453        let bp = TensorWasmKernelBlueprint::new("vector_add")
454            .push(TensorWasmOp::LoadUnified {
455                elem: ElemType::F32,
456                lanes: 4,
457            })
458            .push(TensorWasmOp::VecAdd {
459                elem: ElemType::F32,
460                lanes: 4,
461            })
462            .push(TensorWasmOp::StoreUnified {
463                elem: ElemType::F32,
464                lanes: 4,
465            });
466        let fp = bp.fingerprint();
467        // The exact value is a golden derived from blake3 over the canonical
468        // serialisation declared in `fingerprint()`. Different from
469        // `DefaultHasher` (which was platform-dependent), this value is
470        // identical on every host.
471        assert_ne!(fp, 0);
472        // Sanity: identical blueprint with a tweaked op must differ.
473        let other = TensorWasmKernelBlueprint::new("vector_add")
474            .push(TensorWasmOp::LoadUnified {
475                elem: ElemType::F32,
476                lanes: 4,
477            })
478            .push(TensorWasmOp::VecMul {
479                elem: ElemType::F32,
480                lanes: 4,
481            })
482            .push(TensorWasmOp::StoreUnified {
483                elem: ElemType::F32,
484                lanes: 4,
485            });
486        assert_ne!(fp, other.fingerprint());
487    }
488
489    #[test]
490    fn fingerprint_distinguishes_matmul_dims() {
491        let a = TensorWasmKernelBlueprint::new("k").push(TensorWasmOp::MatMul {
492            m: 16,
493            n: 16,
494            k: 16,
495        });
496        let b = TensorWasmKernelBlueprint::new("k").push(TensorWasmOp::MatMul {
497            m: 32,
498            n: 32,
499            k: 32,
500        });
501        assert_ne!(a.fingerprint(), b.fingerprint());
502    }
503
504    #[test]
505    fn empty_blueprint_detectable() {
506        let bp = TensorWasmKernelBlueprint::new("empty");
507        assert!(bp.is_empty());
508    }
509
510    #[test]
511    fn op_display() {
512        assert_eq!(
513            TensorWasmOp::VecAdd {
514                elem: ElemType::F32,
515                lanes: 4
516            }
517            .to_string(),
518            "vec_add.f32[4]"
519        );
520        assert_eq!(
521            TensorWasmOp::VecAdd {
522                elem: ElemType::I32,
523                lanes: 4
524            }
525            .to_string(),
526            "vec_add.i32[4]"
527        );
528        assert_eq!(
529            TensorWasmOp::MatMul {
530                m: 16,
531                n: 16,
532                k: 16
533            }
534            .to_string(),
535            "matmul[16x16x16]"
536        );
537        assert_eq!(TensorWasmOp::Barrier.to_string(), "barrier");
538    }
539
540    #[test]
541    fn elem_type_byte_width() {
542        assert_eq!(ElemType::I8.byte_width(), 1);
543        assert_eq!(ElemType::I16.byte_width(), 2);
544        assert_eq!(ElemType::I32.byte_width(), 4);
545        assert_eq!(ElemType::F32.byte_width(), 4);
546        assert_eq!(ElemType::I64.byte_width(), 8);
547        assert_eq!(ElemType::F64.byte_width(), 8);
548        assert!(ElemType::F32.is_float());
549        assert!(!ElemType::I32.is_float());
550    }
551}