Skip to main content

tensor_wasm_jit/
lowering_test_support.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Craton Software Company
3
4//! Reusable Cranelift IR test-fixture builders for the wave-1 lowering
5//! passes.
6//!
7//! Each helper here produces a small [`cranelift_codegen::ir::Function`]
8//! with the shape a single `lower_*` pass needs to exercise — an entry
9//! block whose params match the requested signature, one (or zero)
10//! instructions of interest, and a `return` terminator. The lowering
11//! passes (`lower_arith`, `lower_float`, `lower_memory`, `lower_cf`,
12//! `lower_vector`, `lower_conv`) consume these in their integration tests
13//! so they don't each re-invent the Cranelift-construction boilerplate.
14//!
15//! # Scope
16//!
17//! These builders **intentionally** do not use `cranelift-frontend`
18//! (which is not in `tensor-wasm-jit`'s `Cargo.toml`). They build
19//! functions by hand against the `cranelift-codegen` raw DFG + cursor
20//! API. The trade-off: the helpers are limited to single-block bodies
21//! with no SSA-from-frontend convenience. That is the right scope for
22//! wave-1 unit tests; the multi-block lowering tests in `lower_cf` build
23//! their fixtures inline.
24//!
25//! # Why fixtures here (vs inline in each `lower_*` module)
26//!
27//! Wave-1 agents L1-L6 wrote their first-cut fixtures inline because
28//! this module hadn't landed yet. Wave-2 integration tests (the
29//! detector → lowering → blueprint pipeline) need to share fixture
30//! shapes across passes, so this module is the long-term home. The
31//! inline fixtures in `lower_*` modules can migrate here in a future
32//! cleanup pass without changing the test logic — the function shapes
33//! these builders produce are the same shapes those tests already
34//! construct.
35
36#![cfg(feature = "cuda-oxide-backend")]
37
38use cranelift_codegen::cursor::{Cursor, FuncCursor};
39use cranelift_codegen::ir::immediates::Offset32;
40use cranelift_codegen::ir::{
41    AbiParam, Function, Inst, InstBuilder, MemFlags, Opcode, Signature, Type, UserFuncName,
42};
43use cranelift_codegen::isa::CallConv;
44
45/// Pointer-typed parameter used for `load` / `store` fixtures.
46///
47/// Cranelift addresses are integers, not a dedicated pointer type. The
48/// memory-lowering passes match on the 64-bit integer width because
49/// that's the address width PTX targets on sm_80+ (per
50/// [`crate::ptx_emit::DEFAULT_TARGET`]). Using a single named constant
51/// here keeps the `function_with_load` / `function_with_store` helpers
52/// and their unit tests aligned on the same width.
53const PTR_TY: Type = cranelift_codegen::ir::types::I64;
54
55/// Build an empty Cranelift function with the requested signature.
56///
57/// The returned [`Function`] has:
58///
59/// - A `SystemV` calling convention. (The actual CC doesn't matter for
60///   the IR-shape tests these fixtures support — the lowering passes
61///   don't inspect it. SystemV is the workspace default for "any sane
62///   CC will do".)
63/// - One entry block whose block-param list matches `params` 1:1 (same
64///   types, same order).
65/// - A terminating `return` of **zero values**. The caller is expected
66///   to either accept the empty return (e.g. void-returning functions)
67///   or to replace the terminator before lowering when a non-empty
68///   return is required. Replacing the terminator is uncommon in the
69///   wave-1 tests; most callers either use `function_with_*` (which
70///   already inserts a sensible return) or this helper for void
71///   fixtures.
72///
73/// The `name` argument is wrapped via [`UserFuncName::testcase`] so it
74/// shows up readably in `Function::display()` output during test
75/// failures.
76pub fn empty_function(name: &str, params: &[Type], returns: &[Type]) -> Function {
77    let mut sig = Signature::new(CallConv::SystemV);
78    for p in params {
79        sig.params.push(AbiParam::new(*p));
80    }
81    for r in returns {
82        sig.returns.push(AbiParam::new(*r));
83    }
84
85    let mut func = Function::with_name_signature(UserFuncName::testcase(name.as_bytes()), sig);
86    let block = func.dfg.make_block();
87    // Append block params 1:1 with the signature so the entry block is
88    // immediately usable by lowering passes that iterate block params.
89    let param_types: Vec<Type> = func.signature.params.iter().map(|p| p.value_type).collect();
90    for pty in param_types {
91        func.dfg.append_block_param(block, pty);
92    }
93    func.layout.append_block(block);
94
95    // Insert a zero-value `return` so the block has a terminator. Tests
96    // that need a non-empty return (e.g. function_with_binary_op) call
97    // their own InstBuilder::return_ instead of relying on this stub.
98    let mut cursor = FuncCursor::new(&mut func).at_bottom(block);
99    cursor.ins().return_(&[]);
100
101    func
102}
103
104/// Build a function `fn(lane_ty, lane_ty) -> lane_ty` whose entry block
105/// contains a single binary instruction of `opcode` over the two block
106/// params, followed by a return of the result.
107///
108/// Used by the arith / float / vector lowering tests to exercise
109/// per-opcode dispatch. The helper covers any opcode whose
110/// `InstructionFormat` is `Binary` (e.g. `iadd`, `isub`, `imul`, `fadd`,
111/// `fsub`, `fmul`, `fdiv`, `fma` — wait, `fma` is `Ternary`; the rule of
112/// thumb is "two operands, one result, no immediate"). Passing an
113/// opcode whose format is not `Binary` will panic in debug builds via
114/// `cranelift-codegen`'s own `debug_assert_eq!` inside the
115/// `InstBuilder::Binary` shim.
116///
117/// Returns the constructed [`Function`] and the [`Inst`] handle for the
118/// inserted binary op so tests can grep for it without re-walking the
119/// layout.
120pub fn function_with_binary_op(opcode: Opcode, lane_ty: Type) -> (Function, Inst) {
121    let mut func = empty_function_no_return("binop", &[lane_ty, lane_ty], &[lane_ty]);
122    let block = func.layout.entry_block().expect("entry block");
123    let params: Vec<_> = func.dfg.block_params(block).to_vec();
124    let p0 = params[0];
125    let p1 = params[1];
126
127    let mut cursor = FuncCursor::new(&mut func).at_bottom(block);
128    // Use the low-level `Binary` constructor so the helper can dispatch
129    // on a generic Opcode rather than naming one of cranelift's
130    // per-opcode shims. `ctrl_typevar = lane_ty` mirrors what
131    // `iadd` / `fadd` / etc. set internally.
132    let (inst, dfg) = cursor.ins().Binary(opcode, lane_ty, p0, p1);
133    let result = dfg.first_result(inst);
134    cursor.ins().return_(&[result]);
135
136    (func, inst)
137}
138
139/// Build a function `fn(lane_ty) -> lane_ty` whose entry block contains
140/// a single unary instruction of `opcode` over the single block param,
141/// followed by a return of the result.
142///
143/// Symmetric counterpart to [`function_with_binary_op`]; covers any
144/// opcode whose `InstructionFormat` is `Unary` (e.g. `fneg`, `fabs`,
145/// `ineg`, `bnot`). Same debug-assertion caveat applies.
146pub fn function_with_unary_op(opcode: Opcode, lane_ty: Type) -> (Function, Inst) {
147    let mut func = empty_function_no_return("unop", &[lane_ty], &[lane_ty]);
148    let block = func.layout.entry_block().expect("entry block");
149    let params: Vec<_> = func.dfg.block_params(block).to_vec();
150    let p0 = params[0];
151
152    let mut cursor = FuncCursor::new(&mut func).at_bottom(block);
153    let (inst, dfg) = cursor.ins().Unary(opcode, lane_ty, p0);
154    let result = dfg.first_result(inst);
155    cursor.ins().return_(&[result]);
156
157    (func, inst)
158}
159
160/// Build a function `fn(ptr) -> ty` whose entry block contains a single
161/// `load.<ty>` instruction reading from `(param0 + offset)`, followed
162/// by a return of the loaded value.
163///
164/// The pointer param is a 64-bit integer ([`PTR_TY`]) — see the
165/// constant's rustdoc for why we don't use a dedicated pointer type.
166/// `offset` is the byte displacement passed to Cranelift's `load`
167/// instruction; the lowering pass extracts it via
168/// `InstructionData::Load { offset, .. }`.
169///
170/// `MemFlags::trusted()` is used so the lowering doesn't have to model
171/// trap behaviour for the fixture — these tests are about IR shape, not
172/// trap semantics.
173pub fn function_with_load(ty: Type, offset: i32) -> (Function, Inst) {
174    let mut func = empty_function_no_return("load_fn", &[PTR_TY], &[ty]);
175    let block = func.layout.entry_block().expect("entry block");
176    let params: Vec<_> = func.dfg.block_params(block).to_vec();
177    let p0 = params[0];
178
179    let mut cursor = FuncCursor::new(&mut func).at_bottom(block);
180    // Use the low-level `Load` constructor so we get the `Inst` handle
181    // back directly. The shorthand `cursor.ins().load(...)` returns a
182    // `Value` and would require a follow-up `value_def` lookup.
183    let (inst, dfg) = cursor.ins().Load(
184        Opcode::Load,
185        ty,
186        MemFlags::trusted(),
187        Offset32::new(offset),
188        p0,
189    );
190    let result = dfg.first_result(inst);
191    cursor.ins().return_(&[result]);
192
193    (func, inst)
194}
195
196/// Build a function `fn(ty, ptr)` whose entry block contains a single
197/// `store.<ty>` instruction writing `param0` to `(param1 + offset)`,
198/// followed by an empty return.
199///
200/// Parameter order is `(value, pointer)` to match Cranelift's own
201/// `store(MemFlags, x, p, Offset)` builder signature — the convention
202/// the lowering passes already match against. `MemFlags::trusted()` is
203/// used for the same reason as in [`function_with_load`].
204pub fn function_with_store(ty: Type, offset: i32) -> (Function, Inst) {
205    let mut func = empty_function_no_return("store_fn", &[ty, PTR_TY], &[]);
206    let block = func.layout.entry_block().expect("entry block");
207    let params: Vec<_> = func.dfg.block_params(block).to_vec();
208    let p0 = params[0]; // value
209    let p1 = params[1]; // pointer
210
211    let mut cursor = FuncCursor::new(&mut func).at_bottom(block);
212    let (inst, _dfg) = cursor.ins().Store(
213        Opcode::Store,
214        ty,
215        MemFlags::trusted(),
216        Offset32::new(offset),
217        p0,
218        p1,
219    );
220    cursor.ins().return_(&[]);
221
222    (func, inst)
223}
224
225/// Internal helper: build a function with the given signature and an
226/// entry block sized to the signature, but **without** the trailing
227/// `return` that [`empty_function`] inserts. The `function_with_*`
228/// builders use this so they can append their own terminator after
229/// inserting the op of interest.
230fn empty_function_no_return(name: &str, params: &[Type], returns: &[Type]) -> Function {
231    let mut sig = Signature::new(CallConv::SystemV);
232    for p in params {
233        sig.params.push(AbiParam::new(*p));
234    }
235    for r in returns {
236        sig.returns.push(AbiParam::new(*r));
237    }
238    let mut func = Function::with_name_signature(UserFuncName::testcase(name.as_bytes()), sig);
239    let block = func.dfg.make_block();
240    let param_types: Vec<Type> = func.signature.params.iter().map(|p| p.value_type).collect();
241    for pty in param_types {
242        func.dfg.append_block_param(block, pty);
243    }
244    func.layout.append_block(block);
245    func
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use cranelift_codegen::ir::types::{F32, I32, I64};
252
253    /// One block, params match signature, terminator is a zero-value
254    /// return.
255    #[test]
256    fn empty_function_shape() {
257        let func = empty_function("noop", &[I32, F32], &[]);
258        // Exactly one block.
259        assert_eq!(func.layout.blocks().count(), 1);
260        let block = func.layout.entry_block().expect("entry block");
261        // Block params match the signature.
262        assert_eq!(func.dfg.num_block_params(block), 2);
263        let ptys: Vec<Type> = func
264            .dfg
265            .block_params(block)
266            .iter()
267            .map(|v| func.dfg.value_type(*v))
268            .collect();
269        assert_eq!(ptys, vec![I32, F32]);
270        // Final instruction is a return.
271        let last = func.layout.last_inst(block).expect("terminator");
272        assert_eq!(func.dfg.insts[last].opcode(), Opcode::Return);
273    }
274
275    /// `empty_function` honours a non-empty `returns` even though the
276    /// inserted `return` has zero values. Callers are responsible for
277    /// replacing the terminator when the signature demands a value; the
278    /// helper documents that contract and we just lock in the signature
279    /// shape here.
280    #[test]
281    fn empty_function_signature_records_returns() {
282        let func = empty_function("with_ret", &[I32], &[I64]);
283        assert_eq!(func.signature.params.len(), 1);
284        assert_eq!(func.signature.returns.len(), 1);
285        assert_eq!(func.signature.params[0].value_type, I32);
286        assert_eq!(func.signature.returns[0].value_type, I64);
287    }
288
289    /// Binary builder: one block, two params, terminator is `return`,
290    /// inserted instruction is the requested opcode.
291    #[test]
292    fn function_with_binary_op_iadd_shape() {
293        let (func, inst) = function_with_binary_op(Opcode::Iadd, I32);
294        assert_eq!(func.layout.blocks().count(), 1);
295        let block = func.layout.entry_block().unwrap();
296        assert_eq!(func.dfg.num_block_params(block), 2);
297        assert_eq!(func.dfg.insts[inst].opcode(), Opcode::Iadd);
298        // The instruction's operands are the two block params (in
299        // order) — verifies the helper wired param0 to lhs and param1
300        // to rhs, the convention the arith lowering expects.
301        let args = func.dfg.inst_args(inst);
302        let params = func.dfg.block_params(block);
303        assert_eq!(args[0], params[0]);
304        assert_eq!(args[1], params[1]);
305        // Terminator returns the binary result.
306        let last = func.layout.last_inst(block).unwrap();
307        assert_eq!(func.dfg.insts[last].opcode(), Opcode::Return);
308    }
309
310    /// Spot-check that `function_with_binary_op` is opcode-agnostic by
311    /// constructing an `fadd` over `F32` lanes.
312    #[test]
313    fn function_with_binary_op_fadd_shape() {
314        let (func, inst) = function_with_binary_op(Opcode::Fadd, F32);
315        assert_eq!(func.dfg.insts[inst].opcode(), Opcode::Fadd);
316        let block = func.layout.entry_block().unwrap();
317        let ptys: Vec<Type> = func
318            .dfg
319            .block_params(block)
320            .iter()
321            .map(|v| func.dfg.value_type(*v))
322            .collect();
323        assert_eq!(ptys, vec![F32, F32]);
324    }
325
326    /// Unary builder: one block, one param, terminator is `return`,
327    /// inserted instruction is the requested opcode.
328    #[test]
329    fn function_with_unary_op_fneg_shape() {
330        let (func, inst) = function_with_unary_op(Opcode::Fneg, F32);
331        assert_eq!(func.layout.blocks().count(), 1);
332        let block = func.layout.entry_block().unwrap();
333        assert_eq!(func.dfg.num_block_params(block), 1);
334        assert_eq!(func.dfg.insts[inst].opcode(), Opcode::Fneg);
335        let args = func.dfg.inst_args(inst);
336        assert_eq!(args[0], func.dfg.block_params(block)[0]);
337        let last = func.layout.last_inst(block).unwrap();
338        assert_eq!(func.dfg.insts[last].opcode(), Opcode::Return);
339    }
340
341    /// Load builder: one block, one pointer param, load opcode at the
342    /// expected offset, terminator returns the loaded value.
343    #[test]
344    fn function_with_load_shape() {
345        let (func, inst) = function_with_load(I32, 16);
346        assert_eq!(func.layout.blocks().count(), 1);
347        let block = func.layout.entry_block().unwrap();
348        assert_eq!(func.dfg.num_block_params(block), 1);
349        let ptys: Vec<Type> = func
350            .dfg
351            .block_params(block)
352            .iter()
353            .map(|v| func.dfg.value_type(*v))
354            .collect();
355        assert_eq!(ptys, vec![PTR_TY]);
356        assert_eq!(func.dfg.insts[inst].opcode(), Opcode::Load);
357        // Offset is reachable via the InstructionData accessor — the
358        // lowering pass will read it through the same path.
359        let offset = func.dfg.insts[inst]
360            .load_store_offset()
361            .expect("load instruction missing offset");
362        assert_eq!(offset, 16);
363        let last = func.layout.last_inst(block).unwrap();
364        assert_eq!(func.dfg.insts[last].opcode(), Opcode::Return);
365    }
366
367    /// Store builder: one block, two params (value + pointer), store
368    /// opcode at the expected offset, terminator is a zero-value
369    /// return.
370    #[test]
371    fn function_with_store_shape() {
372        let (func, inst) = function_with_store(I32, 32);
373        assert_eq!(func.layout.blocks().count(), 1);
374        let block = func.layout.entry_block().unwrap();
375        assert_eq!(func.dfg.num_block_params(block), 2);
376        let ptys: Vec<Type> = func
377            .dfg
378            .block_params(block)
379            .iter()
380            .map(|v| func.dfg.value_type(*v))
381            .collect();
382        assert_eq!(ptys, vec![I32, PTR_TY]);
383        assert_eq!(func.dfg.insts[inst].opcode(), Opcode::Store);
384        let offset = func.dfg.insts[inst]
385            .load_store_offset()
386            .expect("store instruction missing offset");
387        assert_eq!(offset, 32);
388        let last = func.layout.last_inst(block).unwrap();
389        assert_eq!(func.dfg.insts[last].opcode(), Opcode::Return);
390    }
391}