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}