Repository: bytecodealliance/wasmtime
Pull request #8522 (2024-05-01 21:29:22)

wasi-nn: factor out common test-program code
`wasi-nn`'s test program suite is light at the moment but, in order to expand it, this change factors out some of the common bits that are being used in the `test-programs` crate. Since all of the tests perform some kind of image classification, the new `nn` module gains `classify` and `sort_results` functions to help with this exact case.

<!--
Please make sure you include the following information:

- If this work has been discussed elsewhere, please include a link to that
  conversation. If it was discussed in an issue, just mention "issue #...".

- Explain why this change is needed. If the details are in an issue already,
  this can be brief.

Our development process is documented in the Wasmtime book:
https://docs.wasmtime.dev/contributing-development-process.html

Please ensure all communication follows the code of conduct:
https://github.com/bytecodealliance/wasmtime/blob/main/CODE_OF_CONDUCT.md
-->



Patch:
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 8280db0c5..c9ecf71e9 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -375,6 +375,9 @@ jobs:
     - run: cargo check -p wasmtime-slab
       env:
         CARGO_BUILD_TARGET: x86_64-unknown-none
+    - run: cargo check -p wasmtime-types
+      env:
+        CARGO_BUILD_TARGET: x86_64-unknown-none
 
     # Check that wasmtime compiles with panic=abort since there's some `#[cfg]`
     # for specifically panic=abort there.
diff --git a/Cargo.lock b/Cargo.lock
index c6e7dafea..ed49de52d 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3803,7 +3803,6 @@ dependencies = [
  "serde",
  "serde_derive",
  "smallvec",
- "thiserror",
  "wasmparser 0.206.0",
 ]
 
diff --git a/Cargo.toml b/Cargo.toml
index 5b2be064e..0b74728b5 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -231,7 +231,7 @@ rustix = "0.38.31"
 wit-bindgen = { version = "0.22.0", default-features = false }
 
 # wasm-tools family:
-wasmparser = "0.206.0"
+wasmparser = { version = "0.206.0", default-features = false }
 wat = "1.206.0"
 wast = "206.0.0"
 wasmprinter = "0.206.0"
@@ -270,7 +270,7 @@ proptest = "1.0.0"
 rand = { version = "0.8.3", features = ["small_rng"] }
 sptr = "0.3.2"
 # serde and serde_derive must have the same version
-serde = "1.0.188"
+serde = { version = "1.0.188", default-features = false, features = ['alloc'] }
 serde_derive = "1.0.188"
 serde_json = "1.0.80"
 glob = "0.3.0"
diff --git a/cranelift/codegen/src/lib.rs b/cranelift/codegen/src/lib.rs
index 6e66d31d5..2b24ab579 100644
--- a/cranelift/codegen/src/lib.rs
+++ b/cranelift/codegen/src/lib.rs
@@ -76,6 +76,7 @@ mod iterators;
 mod legalizer;
 mod nan_canonicalization;
 mod opts;
+mod ranges;
 mod remove_constant_phis;
 mod result;
 mod scoped_hash_map;
diff --git a/cranelift/codegen/src/machinst/reg.rs b/cranelift/codegen/src/machinst/reg.rs
index 5ea6efc37..c4e90bc0e 100644
--- a/cranelift/codegen/src/machinst/reg.rs
+++ b/cranelift/codegen/src/machinst/reg.rs
@@ -288,7 +288,6 @@ pub type RegClass = regalloc2::RegClass;
 #[derive(Debug)]
 pub struct OperandCollector<'a, F: Fn(VReg) -> VReg> {
     operands: &'a mut Vec<Operand>,
-    operands_start: usize,
     clobbers: PRegSet,
 
     /// The subset of physical registers that are allocatable.
@@ -300,10 +299,8 @@ pub struct OperandCollector<'a, F: Fn(VReg) -> VReg> {
 impl<'a, F: Fn(VReg) -> VReg> OperandCollector<'a, F> {
     /// Start gathering operands into one flattened operand array.
     pub fn new(operands: &'a mut Vec<Operand>, allocatable: PRegSet, renamer: F) -> Self {
-        let operands_start = operands.len();
         Self {
             operands,
-            operands_start,
             clobbers: PRegSet::default(),
             allocatable,
             renamer,
@@ -313,10 +310,9 @@ impl<'a, F: Fn(VReg) -> VReg> OperandCollector<'a, F> {
     /// Finish the operand collection and return the tuple giving the
     /// range of indices in the flattened operand array, and the
     /// clobber set.
-    pub fn finish(self) -> ((u32, u32), PRegSet) {
-        let start = self.operands_start as u32;
-        let end = self.operands.len() as u32;
-        ((start, end), self.clobbers)
+    pub fn finish(self) -> (usize, PRegSet) {
+        let end = self.operands.len();
+        (end, self.clobbers)
     }
 }
 
diff --git a/cranelift/codegen/src/machinst/vcode.rs b/cranelift/codegen/src/machinst/vcode.rs
index f526958f6..5acb35240 100644
--- a/cranelift/codegen/src/machinst/vcode.rs
+++ b/cranelift/codegen/src/machinst/vcode.rs
@@ -20,6 +20,7 @@
 use crate::ir::pcc::*;
 use crate::ir::{self, types, Constant, ConstantData, ValueLabel};
 use crate::machinst::*;
+use crate::ranges::Ranges;
 use crate::timing;
 use crate::trace;
 use crate::CodegenError;
@@ -75,7 +76,7 @@ pub struct VCode<I: VCodeInst> {
     /// Operand index ranges: for each instruction in `insts`, there
     /// is a tuple here providing the range in `operands` for that
     /// instruction's operands.
-    operand_ranges: Vec<(u32, u32)>,
+    operand_ranges: Ranges,
 
     /// Clobbers: a sparse map from instruction indices to clobber masks.
     clobbers: FxHashMap<InsnIndex, PRegSet>,
@@ -88,10 +89,10 @@ pub struct VCode<I: VCodeInst> {
     entry: BlockIndex,
 
     /// Block instruction indices.
-    block_ranges: Vec<(InsnIndex, InsnIndex)>,
+    block_ranges: Ranges,
 
     /// Block successors: index range in the `block_succs` list.
-    block_succ_range: Vec<(u32, u32)>,
+    block_succ_range: Ranges,
 
     /// Block successor lists, concatenated into one vec. The
     /// `block_succ_range` list of tuples above gives (start, end)
@@ -100,7 +101,7 @@ pub struct VCode<I: VCodeInst> {
     block_succs: Vec<regalloc2::Block>,
 
     /// Block predecessors: index range in the `block_preds` list.
-    block_pred_range: Vec<(u32, u32)>,
+    block_pred_range: Ranges,
 
     /// Block predecessor lists, concatenated into one vec. The
     /// `block_pred_range` list of tuples above gives (start, end)
@@ -109,7 +110,7 @@ pub struct VCode<I: VCodeInst> {
     block_preds: Vec<regalloc2::Block>,
 
     /// Block parameters: index range in `block_params` below.
-    block_params_range: Vec<(u32, u32)>,
+    block_params_range: Ranges,
 
     /// Block parameter lists, concatenated into one vec. The
     /// `block_params_range` list of tuples above gives (start, end)
@@ -133,11 +134,11 @@ pub struct VCode<I: VCodeInst> {
     /// for each block are concatenated.
     ///
     /// Indexed by the indices in `branch_block_arg_succ_range`.
-    branch_block_arg_range: Vec<(u32, u32)>,
+    branch_block_arg_range: Ranges,
 
     /// For a given block, indices in `branch_block_arg_range`
     /// corresponding to all of its successors.
-    branch_block_arg_succ_range: Vec<(u32, u32)>,
+    branch_block_arg_succ_range: Ranges,
 
     /// Block-order information.
     block_order: BlockLoweringOrder,
@@ -226,20 +227,6 @@ pub struct VCodeBuilder<I: VCodeInst> {
     /// In what direction is the build occuring?
     direction: VCodeBuildDirection,
 
-    /// Index of the last block-start in the vcode.
-    block_start: usize,
-
-    /// Start of succs for the current block in the concatenated succs list.
-    succ_start: usize,
-
-    /// Start of blockparams for the current block in the concatenated
-    /// blockparams list.
-    block_params_start: usize,
-
-    /// Start of successor blockparam arg list entries in
-    /// the concatenated branch_block_arg_range list.
-    branch_block_arg_succ_start: usize,
-
     /// Debug-value label in-progress map, keyed by label. For each
     /// label, we keep disjoint ranges mapping to vregs. We'll flatten
     /// this into (vreg, range, label) tuples when done.
@@ -270,10 +257,6 @@ impl<I: VCodeInst> VCodeBuilder<I> {
         VCodeBuilder {
             vcode,
             direction,
-            block_start: 0,
-            succ_start: 0,
-            block_params_start: 0,
-            branch_block_arg_succ_start: 0,
             debug_info: FxHashMap::default(),
         }
     }
@@ -313,32 +296,20 @@ impl<I: VCodeInst> VCodeBuilder<I> {
     /// End the current basic block. Must be called after emitting vcode insts
     /// for IR insts and prior to ending the function (building the VCode).
     pub fn end_bb(&mut self) {
-        let start_idx = self.block_start;
         let end_idx = self.vcode.insts.len();
-        self.block_start = end_idx;
         // Add the instruction index range to the list of blocks.
-        self.vcode
-            .block_ranges
-            .push((InsnIndex::new(start_idx), InsnIndex::new(end_idx)));
+        self.vcode.block_ranges.push_end(end_idx);
         // End the successors list.
         let succ_end = self.vcode.block_succs.len();
-        self.vcode
-            .block_succ_range
-            .push((self.succ_start as u32, succ_end as u32));
-        self.succ_start = succ_end;
+        self.vcode.block_succ_range.push_end(succ_end);
         // End the blockparams list.
         let block_params_end = self.vcode.block_params.len();
-        self.vcode
-            .block_params_range
-            .push((self.block_params_start as u32, block_params_end as u32));
-        self.block_params_start = block_params_end;
+        self.vcode.block_params_range.push_end(block_params_end);
         // End the branch blockparam args list.
         let branch_block_arg_succ_end = self.vcode.branch_block_arg_range.len();
-        self.vcode.branch_block_arg_succ_range.push((
-            self.branch_block_arg_succ_start as u32,
-            branch_block_arg_succ_end as u32,
-        ));
-        self.branch_block_arg_succ_start = branch_block_arg_succ_end;
+        self.vcode
+            .branch_block_arg_succ_range
+            .push_end(branch_block_arg_succ_end);
     }
 
     pub fn add_block_param(&mut self, param: VirtualReg) {
@@ -346,14 +317,11 @@ impl<I: VCodeInst> VCodeBuilder<I> {
     }
 
     fn add_branch_args_for_succ(&mut self, args: &[Reg]) {
-        let start = self.vcode.branch_block_args.len();
         self.vcode
             .branch_block_args
             .extend(args.iter().map(|&arg| VReg::from(arg)));
         let end = self.vcode.branch_block_args.len();
-        self.vcode
-            .branch_block_arg_range
-            .push((start as u32, end as u32));
+        self.vcode.branch_block_arg_range.push_end(end);
     }
 
     /// Push an instruction for the current BB and current IR inst
@@ -407,20 +375,20 @@ impl<I: VCodeInst> VCodeBuilder<I> {
             let start = end;
             end += *count;
             *count = start;
-            self.vcode.block_pred_range.push((start, end));
+            self.vcode.block_pred_range.push_end(end as usize);
         }
         let end = end as usize;
         debug_assert_eq!(end, self.vcode.block_succs.len());
 
         // Walk over the successors again, this time grouped by
         // predecessor, and push the predecessor at the current
-        // starting position of each of its successors. Visiting
-        // predecessor groups in ascending order means we build each
-        // group of predecessors in sorted order too.
+        // starting position of each of its successors. We build
+        // each group of predecessors in whatever order Ranges::iter
+        // returns them; regalloc2 doesn't care.
         self.vcode.block_preds.resize(end, BlockIndex::invalid());
-        for (pred, &(start, end)) in self.vcode.block_succ_range.iter().enumerate() {
+        for (pred, range) in self.vcode.block_succ_range.iter() {
             let pred = BlockIndex::new(pred);
-            for succ in &self.vcode.block_succs[start as usize..end as usize] {
+            for succ in &self.vcode.block_succs[range] {
                 let pos = &mut starts[succ.index()];
                 self.vcode.block_preds[*pos as usize] = pred;
                 *pos += 1;
@@ -439,21 +407,22 @@ impl<I: VCodeInst> VCodeBuilder<I> {
         }
 
         // Reverse the per-block and per-inst sequences.
-        self.vcode.block_ranges.reverse();
+        self.vcode.block_ranges.reverse_index();
+        self.vcode.block_ranges.reverse_target(n_insts);
         // block_params_range is indexed by block (and blocks were
         // traversed in reverse) so we reverse it; but block-param
         // sequences in the concatenated vec can remain in reverse
         // order (it is effectively an arena of arbitrarily-placed
         // referenced sequences).
-        self.vcode.block_params_range.reverse();
+        self.vcode.block_params_range.reverse_index();
         // Likewise, we reverse block_succ_range, but the block_succ
         // concatenated array can remain as-is.
-        self.vcode.block_succ_range.reverse();
+        self.vcode.block_succ_range.reverse_index();
         self.vcode.insts.reverse();
         self.vcode.srclocs.reverse();
         // Likewise, branch_block_arg_succ_range is indexed by block
         // so must be reversed.
-        self.vcode.branch_block_arg_succ_range.reverse();
+        self.vcode.branch_block_arg_succ_range.reverse_index();
 
         // To translate an instruction index *endpoint* in reversed
         // order to forward order, compute `n_insts - i`.
@@ -467,12 +436,6 @@ impl<I: VCodeInst> VCodeBuilder<I> {
         // (exclusive) end of 10.
         let translate = |inst: InsnIndex| InsnIndex::new(n_insts - inst.index());
 
-        // Edit the block-range instruction indices.
-        for tuple in &mut self.vcode.block_ranges {
-            let (start, end) = *tuple;
-            *tuple = (translate(end), translate(start)); // Note reversed order.
-        }
-
         // Generate debug-value labels based on per-label maps.
         for (label, tuples) in &self.debug_info {
             for &(start, end, vreg) in tuples {
@@ -512,7 +475,7 @@ impl<I: VCodeInst> VCodeBuilder<I> {
                 });
             insn.get_operands(&mut op_collector);
             let (ops, clobbers) = op_collector.finish();
-            self.vcode.operand_ranges.push(ops);
+            self.vcode.operand_ranges.push_end(ops);
 
             if clobbers != PRegSet::default() {
                 self.vcode.clobbers.insert(InsnIndex::new(i), clobbers);
@@ -626,20 +589,20 @@ impl<I: VCodeInst> VCode<I> {
             vreg_types: vec![],
             insts: Vec::with_capacity(10 * n_blocks),
             operands: Vec::with_capacity(30 * n_blocks),
-            operand_ranges: Vec::with_capacity(10 * n_blocks),
+            operand_ranges: Ranges::with_capacity(10 * n_blocks),
             clobbers: FxHashMap::default(),
             srclocs: Vec::with_capacity(10 * n_blocks),
             entry: BlockIndex::new(0),
-            block_ranges: Vec::with_capacity(n_blocks),
-            block_succ_range: Vec::with_capacity(n_blocks),
+            block_ranges: Ranges::with_capacity(n_blocks),
+            block_succ_range: Ranges::with_capacity(n_blocks),
             block_succs: Vec::with_capacity(n_blocks),
-            block_pred_range: Vec::new(),
+            block_pred_range: Ranges::default(),
             block_preds: Vec::new(),
-            block_params_range: Vec::with_capacity(n_blocks),
+            block_params_range: Ranges::with_capacity(n_blocks),
             block_params: Vec::with_capacity(5 * n_blocks),
             branch_block_args: Vec::with_capacity(10 * n_blocks),
-            branch_block_arg_range: Vec::with_capacity(2 * n_blocks),
-            branch_block_arg_succ_range: Vec::with_capacity(n_blocks),
+            branch_block_arg_range: Ranges::with_capacity(2 * n_blocks),
+            branch_block_arg_succ_range: Ranges::with_capacity(n_blocks),
             block_order,
             abi,
             emit_info,
@@ -676,7 +639,7 @@ impl<I: VCodeInst> VCode<I> {
             }
         }
 
-        for (i, (start, end)) in self.operand_ranges.iter().enumerate() {
+        for (i, range) in self.operand_ranges.iter() {
             // Skip this instruction if not "included in clobbers" as
             // per the MachInst. (Some backends use this to implement
             // ABI specifics; e.g., excluding calls of the same ABI as
@@ -687,10 +650,8 @@ impl<I: VCodeInst> VCode<I> {
                 continue;
             }
 
-            let start = *start as usize;
-            let end = *end as usize;
-            let operands = &self.operands[start..end];
-            let allocs = &regalloc.allocs[start..end];
+            let operands = &self.operands[range.clone()];
+            let allocs = &regalloc.allocs[range];
             for (operand, alloc) in operands.iter().zip(allocs.iter()) {
                 if operand.kind() == OperandKind::Def {
                     if let Some(preg) = alloc.as_reg() {
@@ -787,7 +748,7 @@ impl<I: VCodeInst> VCode<I> {
         let mut ra_edits_per_block: SmallVec<[u32; 64]> = smallvec![];
         let mut edit_idx = 0;
         for block in 0..self.num_blocks() {
-            let end_inst = self.block_ranges[block].1;
+            let end_inst = InsnIndex::new(self.block_ranges.get(block).end);
             let start_edit_idx = edit_idx;
             while edit_idx < regalloc.edits.len() && regalloc.edits[edit_idx].0.inst() < end_inst {
                 edit_idx += 1;
@@ -1004,9 +965,8 @@ impl<I: VCodeInst> VCode<I> {
             // `MachBuffer` to determine if an island is necessary.
             let worst_case_next_bb = if block_order_idx < final_order.len() - 1 {
                 let next_block = final_order[block_order_idx + 1];
-                let next_block_range = self.block_ranges[next_block.index()];
-                let next_block_size =
-                    (next_block_range.1.index() - next_block_range.0.index()) as u32;
+                let next_block_range = self.block_ranges.get(next_block.index());
+                let next_block_size = next_block_range.len() as u32;
                 let next_block_ra_insertions = ra_edits_per_block[next_block.index()];
                 I::worst_case_size() * (next_block_size + next_block_ra_insertions)
             } else {
@@ -1270,18 +1230,18 @@ impl<I: VCodeInst> RegallocFunction for VCode<I> {
     }
 
     fn block_insns(&self, block: BlockIndex) -> InstRange {
-        let (start, end) = self.block_ranges[block.index()];
-        InstRange::forward(start, end)
+        let range = self.block_ranges.get(block.index());
+        InstRange::forward(InsnIndex::new(range.start), InsnIndex::new(range.end))
     }
 
     fn block_succs(&self, block: BlockIndex) -> &[BlockIndex] {
-        let (start, end) = self.block_succ_range[block.index()];
-        &self.block_succs[start as usize..end as usize]
+        let range = self.block_succ_range.get(block.index());
+        &self.block_succs[range]
     }
 
     fn block_preds(&self, block: BlockIndex) -> &[BlockIndex] {
-        let (start, end) = self.block_pred_range[block.index()];
-        &self.block_preds[start as usize..end as usize]
+        let range = self.block_pred_range.get(block.index());
+        &self.block_preds[range]
     }
 
     fn block_params(&self, block: BlockIndex) -> &[VReg] {
@@ -1291,16 +1251,15 @@ impl<I: VCodeInst> RegallocFunction for VCode<I> {
             return &[];
         }
 
-        let (start, end) = self.block_params_range[block.index()];
-        &self.block_params[start as usize..end as usize]
+        let range = self.block_params_range.get(block.index());
+        &self.block_params[range]
     }
 
     fn branch_blockparams(&self, block: BlockIndex, _insn: InsnIndex, succ_idx: usize) -> &[VReg] {
-        let (succ_range_start, succ_range_end) = self.branch_block_arg_succ_range[block.index()];
-        let succ_ranges =
-            &self.branch_block_arg_range[succ_range_start as usize..succ_range_end as usize];
-        let (branch_block_args_start, branch_block_args_end) = succ_ranges[succ_idx];
-        &self.branch_block_args[branch_block_args_start as usize..branch_block_args_end as usize]
+        let succ_range = self.branch_block_arg_succ_range.get(block.index());
+        debug_assert!(succ_idx < succ_range.len());
+        let branch_block_args = self.branch_block_arg_range.get(succ_range.start + succ_idx);
+        &self.branch_block_args[branch_block_args]
     }
 
     fn is_ret(&self, insn: InsnIndex) -> bool {
@@ -1324,8 +1283,8 @@ impl<I: VCodeInst> RegallocFunction for VCode<I> {
     }
 
     fn inst_operands(&self, insn: InsnIndex) -> &[Operand] {
-        let (start, end) = self.operand_ranges[insn.index()];
-        &self.operands[start as usize..end as usize]
+        let range = self.operand_ranges.get(insn.index());
+        &self.operands[range]
     }
 
     fn inst_clobbers(&self, insn: InsnIndex) -> PRegSet {
@@ -1393,8 +1352,7 @@ impl<I: VCodeInst> fmt::Debug for VCode<I> {
             for succ in self.block_succs(block) {
                 writeln!(f, "    (successor: Block {})", succ.index())?;
             }
-            let (start, end) = self.block_ranges[block.index()];
-            for inst in start.index()..end.index() {
+            for inst in self.block_ranges.get(block.index()) {
                 writeln!(
                     f,
                     "  Inst {}: {}",
diff --git a/cranelift/codegen/src/opts/arithmetic.isle b/cranelift/codegen/src/opts/arithmetic.isle
index 2a775d93c..4c825afb1 100644
--- a/cranelift/codegen/src/opts/arithmetic.isle
+++ b/cranelift/codegen/src/opts/arithmetic.isle
@@ -152,6 +152,67 @@
 (rule (simplify (bxor ty (bxor ty (bxor ty a b) c) d))
       (subsume (bxor ty (bxor ty a b) (bxor ty c d))))
 
+
+;; Similar rules but for associating combinations of + and -
+
+;; a -(b-(c-d)) = (a-b) + (c-d)
+(rule (simplify (isub ty a (isub ty b (isub ty c d))))
+      (subsume (iadd ty (isub ty a b) (isub ty c d))))
+
+;; a -(b-(c+d)) = (a-b) + (c+d)
+(rule (simplify (isub ty a (isub ty b (iadd ty c d))))
+      (subsume (iadd ty (isub ty a b) (iadd ty c d))))
+
+;; a -(b+(c-d)) = (a-b) - (c-d)
+(rule (simplify (isub ty a (iadd ty b (isub ty c d))))
+      (subsume (isub ty (isub ty a b) (isub ty c d))))
+
+;; a -(b+(c+d)) = (a-b) - (c+d)
+(rule (simplify (isub ty a (iadd ty b (iadd ty c d))))
+      (subsume (isub ty (isub ty a b) (iadd ty c d))))
+
+;; a +(b-(c-d)) = (a+b) - (c-d)
+(rule (simplify (iadd ty a (isub ty b (isub ty c d))))
+      (subsume (isub ty (iadd ty a b) (isub ty c d))))
+
+;; a +(b-(c+d)) = (a+b) - (c+d)
+(rule (simplify (iadd ty a (isub ty b (iadd ty c d))))
+      (subsume (isub ty (iadd ty a b) (iadd ty c d))))
+
+;; a +(b+(c-d)) = (a+b) + (c-d)
+(rule (simplify (iadd ty a (iadd ty b (isub ty c d))))
+      (subsume (iadd ty (iadd ty a b) (isub ty c d))))
+
+;; and nested the other way
+
+;; ((a-b)-c)-d = (a-b) - (c+d)
+(rule (simplify (isub ty (isub ty (isub ty a b) c) d))
+      (subsume (isub ty (isub ty a b) (iadd ty c d))))
+
+;; ((a-b)-c)+d = (a-b) - (c-d)
+(rule (simplify (iadd ty (isub ty (isub ty a b) c) d))
+      (subsume (isub ty (isub ty a b) (isub ty c d))))
+
+;; ((a-b)+c)-d = (a-b) + (c-d)
+(rule (simplify (isub ty (iadd ty (isub ty a b) c) d))
+      (subsume (iadd ty (isub ty a b) (isub ty c d))))
+
+;; ((a-b)+c)+d = (a-b) + (c+d)
+(rule (simplify (iadd ty (iadd ty (isub ty a b) c) d))
+      (subsume (iadd ty (isub ty a b) (iadd ty c d))))
+
+;; ((a+b)-c)-d = (a+b) - (c+d)
+(rule (simplify (isub ty (isub ty (iadd ty a b) c) d))
+      (subsume (isub ty (iadd ty a b) (iadd ty c d))))
+
+;; ((a+b)-c)+d = (a+b) - (c-d)
+(rule (simplify (iadd ty (isub ty (iadd ty a b) c) d))
+      (subsume (isub ty (iadd ty a b) (isub ty c d))))
+
+;; ((a+b)+c)-d = (a+b) + (c-d)
+(rule (simplify (isub ty (iadd ty (iadd ty a b) c) d))
+      (subsume (iadd ty (iadd ty a b) (isub ty c d))))
+
 ;; Detect people open-coding `mulhi`: (x as big * y as big) >> bits
 ;; LLVM doesn't have an intrinsic for it, so you'll see it in code like
 ;; <https://github.com/rust-lang/rust/blob/767453eb7ca188e991ac5568c17b984dd4893e77/library/core/src/num/mod.rs#L174-L180>
diff --git a/cranelift/codegen/src/ranges.rs b/cranelift/codegen/src/ranges.rs
new file mode 100644
index 000000000..419dee83c
--- /dev/null
+++ b/cranelift/codegen/src/ranges.rs
@@ -0,0 +1,131 @@
+//! The [`Ranges`] type stores a list of contiguous index ranges that
+//! span some other list's full length.
+
+use alloc::vec::Vec;
+use core::ops::Range;
+
+/// A list of contiguous index ranges.
+#[derive(Default)]
+pub struct Ranges {
+    ranges: Vec<u32>,
+    reverse: bool,
+}
+
+impl Ranges {
+    /// Constructs a new, empty, list of ranges with at least the
+    /// specified capacity.
+    pub fn with_capacity(capacity: usize) -> Self {
+        let mut new = Ranges::default();
+        new.reserve(capacity);
+        new
+    }
+
+    /// Add a new range which begins at the end of the previous range
+    /// and ends at the specified offset, exclusive.
+    pub fn push_end(&mut self, end: usize) {
+        debug_assert!(!self.reverse);
+        // To keep this implementation simple we explicitly store the
+        // starting index, which is always 0, so that all ranges are
+        // represented by adjacent pairs in the list. But we add it
+        // lazily so that an empty list doesn't have to allocate.
+        if self.ranges.is_empty() {
+            self.ranges.push(0);
+        }
+        self.ranges.push(u32::try_from(end).unwrap());
+    }
+
+    /// Number of ranges in this list.
+    pub fn len(&self) -> usize {
+        self.ranges.len().saturating_sub(1)
+    }
+
+    /// Reserves capacity for at least `additional` more ranges to be
+    /// added to this list.
+    pub fn reserve(&mut self, mut additional: usize) {
+        if additional > 0 && self.ranges.is_empty() {
+            additional = additional.saturating_add(1);
+        }
+        self.ranges.reserve(additional);
+    }
+
+    /// Get the range at the specified index.
+    pub fn get(&self, index: usize) -> Range<usize> {
+        let len = self.len();
+        assert!(index < len, "index {index} is too big for length {len}");
+        let index = self.map_index(index);
+        self.ranges[index] as usize..self.ranges[index + 1] as usize
+    }
+
+    /// Visit ranges in unspecified order, paired with the index each
+    /// range occurs at.
+    pub fn iter(
+        &self,
+    ) -> impl DoubleEndedIterator<Item = (usize, Range<usize>)> + ExactSizeIterator + '_ {
+        self.ranges
+            .windows(2)
+            .enumerate()
+            .map(|(index, range)| (self.map_index(index), range[0] as usize..range[1] as usize))
+    }
+
+    /// Reverse this list of ranges, so that the first range is at the
+    /// last index and the last range is at the first index.
+    ///
+    /// ```ignore
+    /// use cranelift_codegen::ranges::Ranges;
+    /// let mut ranges = Ranges::default();
+    /// ranges.push_end(4);
+    /// ranges.push_end(6);
+    /// ranges.reverse_index();
+    /// assert_eq!(ranges.get(0), 4..6);
+    /// assert_eq!(ranges.get(1), 0..4);
+    /// ```
+    pub fn reverse_index(&mut self) {
+        // We can't easily change the order of the endpoints in
+        // self.ranges: they need to be in ascending order or our
+        // compressed representation gets complicated. So instead we
+        // change our interpretation of indexes using map_index below,
+        // controlled by a simple flag. As a bonus, reversing the list
+        // is constant-time!
+        self.reverse = !self.reverse;
+    }
+
+    fn map_index(&self, index: usize) -> usize {
+        if self.reverse {
+            // These subtractions can't overflow because callers
+            // enforce that 0 <= index < self.len()
+            self.len() - 1 - index
+        } else {
+            index
+        }
+    }
+
+    /// Update these ranges to reflect that the list they refer to has
+    /// been reversed. Afterwards, the ranges will still be indexed
+    /// in the same order, but the first range will refer to the
+    /// same-length range at the end of the target list instead of at
+    /// the beginning, and subsequent ranges will proceed backwards
+    /// from there.
+    ///
+    /// ```ignore
+    /// use cranelift_codegen::ranges::Ranges;
+    /// let mut ranges = Ranges::default();
+    /// ranges.push_end(4);
+    /// ranges.push_end(6);
+    /// ranges.reverse_target(6);
+    /// assert_eq!(ranges.get(0), 2..6);
+    /// assert_eq!(ranges.get(1), 0..2);
+    /// ```
+    pub fn reverse_target(&mut self, target_len: usize) {
+        let target_len = u32::try_from(target_len).unwrap();
+        // The last endpoint added should be the same as the current
+        // length of the target list.
+        debug_assert_eq!(target_len, *self.ranges.last().unwrap_or(&0));
+        for end in self.ranges.iter_mut() {
+            *end = target_len - *end;
+        }
+        // Put the endpoints back in ascending order, but that means
+        // now our indexes are backwards.
+        self.ranges.reverse();
+        self.reverse_index();
+    }
+}
diff --git a/crates/cli-flags/src/lib.rs b/crates/cli-flags/src/lib.rs
index 5d0c3f8f8..320b76e6c 100644
--- a/crates/cli-flags/src/lib.rs
+++ b/crates/cli-flags/src/lib.rs
@@ -244,6 +244,15 @@ wasmtime_option_group! {
         pub function_references: Option<bool>,
         /// Configure support for the GC proposal.
         pub gc: Option<bool>,
+        /// Whether to enable call-indirect caching.
+        pub cache_call_indirects: Option<bool>,
+        /// The maximum call-indirect cache slot count.
+        ///
+        /// One slot is allocated per indirect callsite; if the module
+        /// has more indirect callsites than this limit, then the
+        /// first callsites in linear order in the code section, up to
+        /// the limit, will receive a cache slot.
+        pub max_call_indirect_cache_slots: Option<usize>,
     }
 
     enum Wasm {
@@ -544,6 +553,12 @@ impl CommonOptions {
         if let Some(enable) = self.opts.memory_init_cow {
             config.memory_init_cow(enable);
         }
+        if let Some(enable) = self.wasm.cache_call_indirects {
+            config.cache_call_indirects(enable);
+        }
+        if let Some(max) = self.wasm.max_call_indirect_cache_slots {
+            config.max_call_indirect_cache_slots(max);
+        }
 
         match_feature! {
             ["pooling-allocator" : self.opts.pooling_allocator]
diff --git a/crates/cranelift/src/compiler.rs b/crates/cranelift/src/compiler.rs
index 7d89ef5f8..fed9ac988 100644
--- a/crates/cranelift/src/compiler.rs
+++ b/crates/cranelift/src/compiler.rs
@@ -148,8 +148,14 @@ impl wasmtime_environ::Compiler for Compiler {
             context.func.collect_debug_info();
         }
 
-        let mut func_env =
-            FuncEnvironment::new(isa, translation, types, &self.tunables, self.wmemcheck);
+        let mut func_env = FuncEnvironment::new(
+            isa,
+            translation,
+            types,
+            &self.tunables,
+            self.wmemcheck,
+            input.call_indirect_start,
+        );
 
         // The `stack_limit` global value below is the implementation of stack
         // overflow checks in Wasmtime.
@@ -203,7 +209,11 @@ impl wasmtime_environ::Compiler for Compiler {
             flags: MemFlags::trusted(),
         });
         context.func.stack_limit = Some(stack_limit);
-        let FunctionBodyData { validator, body } = input;
+        let FunctionBodyData {
+            validator,
+            body,
+            call_indirect_start: _,
+        } = input;
         let mut validator =
             validator.into_validator(mem::take(&mut compiler.cx.validator_allocations));
         compiler.cx.func_translator.translate_body(
diff --git a/crates/cranelift/src/func_environ.rs b/crates/cranelift/src/func_environ.rs
index 5547b6c40..7d11944bc 100644
--- a/crates/cranelift/src/func_environ.rs
+++ b/crates/cranelift/src/func_environ.rs
@@ -11,9 +11,9 @@ use cranelift_entity::{EntityRef, PrimaryMap, SecondaryMap};
 use cranelift_frontend::FunctionBuilder;
 use cranelift_frontend::Variable;
 use cranelift_wasm::{
-    EngineOrModuleTypeIndex, FuncIndex, FuncTranslationState, GlobalIndex, GlobalVariable, Heap,
-    HeapData, HeapStyle, MemoryIndex, TableData, TableIndex, TableSize, TargetEnvironment,
-    TypeIndex, WasmHeapTopType, WasmHeapType, WasmResult,
+    CallIndirectSiteIndex, EngineOrModuleTypeIndex, FuncIndex, FuncTranslationState, GlobalIndex,
+    GlobalVariable, Heap, HeapData, HeapStyle, MemoryIndex, TableData, TableIndex, TableSize,
+    TargetEnvironment, TypeIndex, WasmHeapTopType, WasmHeapType, WasmResult,
 };
 use std::mem;
 use wasmparser::Operator;
@@ -137,6 +137,9 @@ pub struct FuncEnvironment<'module_environment> {
 
     #[cfg(feature = "wmemcheck")]
     wmemcheck: bool,
+
+    /// The current call-indirect-cache index.
+    pub call_indirect_index: usize,
 }
 
 impl<'module_environment> FuncEnvironment<'module_environment> {
@@ -146,6 +149,7 @@ impl<'module_environment> FuncEnvironment<'module_environment> {
         types: &'module_environment ModuleTypesBuilder,
         tunables: &'module_environment Tunables,
         wmemcheck: bool,
+        call_indirect_start: usize,
     ) -> Self {
         let builtin_functions = BuiltinFunctions::new(isa);
 
@@ -172,6 +176,9 @@ impl<'module_environment> FuncEnvironment<'module_environment> {
             // Start with at least one fuel being consumed because even empty
             // functions should consume at least some fuel.
             fuel_consumed: 1,
+
+            call_indirect_index: call_indirect_start,
+
             #[cfg(feature = "wmemcheck")]
             wmemcheck,
             #[cfg(feature = "wmemcheck")]
@@ -809,6 +816,7 @@ impl<'module_environment> FuncEnvironment<'module_environment> {
         builder: &mut FunctionBuilder,
         table_index: TableIndex,
         index: ir::Value,
+        cold_blocks: bool,
     ) -> ir::Value {
         let pointer_type = self.pointer_type();
         self.ensure_table_exists(builder.func, table_index);
@@ -836,6 +844,10 @@ impl<'module_environment> FuncEnvironment<'module_environment> {
 
         let null_block = builder.create_block();
         let continuation_block = builder.create_block();
+        if cold_blocks {
+            builder.set_cold_block(null_block);
+            builder.set_cold_block(continuation_block);
+        }
         let result_param = builder.append_block_param(continuation_block, pointer_type);
         builder.set_cold_block(null_block);
 
@@ -995,6 +1007,37 @@ impl<'module_environment> FuncEnvironment<'module_environment> {
             _ => unreachable!(),
         }
     }
+
+    /// Allocate the next CallIndirectSiteIndex for indirect-target
+    /// caching purposes, if slots remain below the slot-count limit.
+    fn alloc_call_indirect_index(&mut self) -> Option<CallIndirectSiteIndex> {
+        // We need to check to see if we have reached the cache-slot
+        // limit.
+        //
+        // There are two kinds of limit behavior:
+        //
+        // 1. Our function's start-index is below the limit, but we
+        //    hit the limit in the middle of the function. We will
+        //    allocate slots up to the limit, then stop exactly when we
+        //    hit it.
+        //
+        // 2. Our function is beyond the limit-count of
+        //    `call_indirect`s. The counting prescan in
+        //    `ModuleEnvironment` that assigns start indices will
+        //    saturate at the limit, and this function's start index
+        //    will be exactly the limit, so we get zero slots and exit
+        //    immediately at every call to this function.
+        if self.call_indirect_index >= self.tunables.max_call_indirect_cache_slots {
+            return None;
+        }
+
+        let idx = CallIndirectSiteIndex::from_u32(
+            u32::try_from(self.call_indirect_index)
+                .expect("More than 2^32 callsites; should be limited by impl limits"),
+        );
+        self.call_indirect_index += 1;
+        Some(idx)
+    }
 }
 
 struct Call<'a, 'func, 'module_env> {
@@ -1106,6 +1149,68 @@ impl<'a, 'func, 'module_env> Call<'a, 'func, 'module_env> {
         Ok(self.indirect_call_inst(sig_ref, func_addr, &real_call_args))
     }
 
+    /// Get the address of the call-indirect cache slot for a given callsite.
+    pub fn call_indirect_cache_slot_addr(
+        &mut self,
+        call_site: CallIndirectSiteIndex,
+        vmctx: ir::Value,
+    ) -> ir::Value {
+        let offset = self.env.offsets.vmctx_call_indirect_cache(call_site);
+        self.builder.ins().iadd_imm(vmctx, i64::from(offset))
+    }
+
+    /// Load the cached index and code pointer for an indirect call.
+    ///
+    /// Generates IR like:
+    ///
+    /// ```ignore
+    /// v1 = load.i64 cache_ptr+0 ;; cached index (cache key)
+    /// v2 = load.i64 cache_ptr+8 ;; cached raw code pointer (cache value)
+    /// ```
+    ///
+    /// and returns `(index, code_ptr)` (e.g. from above, `(v1, v2)`).
+    fn load_cached_indirect_index_and_code_ptr(
+        &mut self,
+        cache_ptr: ir::Value,
+    ) -> (ir::Value, ir::Value) {
+        let cached_index = self.builder.ins().load(
+            I32,
+            MemFlags::trusted(),
+            cache_ptr,
+            Offset32::from(self.env.offsets.ptr.vmcall_indirect_cache_index()),
+        );
+        let cached_code_ptr = self.builder.ins().load(
+            self.env.pointer_type(),
+            MemFlags::trusted(),
+            cache_ptr,
+            Offset32::from(self.env.offsets.ptr.vmcall_indirect_cache_wasm_call()),
+        );
+
+        (cached_index, cached_code_ptr)
+    }
+
+    /// Update the indirect-call cache: store a new index and raw code
+    /// pointer in the slot for a given callsite.
+    fn store_cached_indirect_index_and_code_ptr(
+        &mut self,
+        cache_ptr: ir::Value,
+        index: ir::Value,
+        code_ptr: ir::Value,
+    ) {
+        self.builder.ins().store(
+            MemFlags::trusted(),
+            index,
+            cache_ptr,
+            Offset32::from(self.env.offsets.ptr.vmcall_indirect_cache_index()),
+        );
+        self.builder.ins().store(
+            MemFlags::trusted(),
+            code_ptr,
+            cache_ptr,
+            Offset32::from(self.env.offsets.ptr.vmcall_indirect_cache_wasm_call()),
+        );
+    }
+
     /// Do an indirect call through the given funcref table.
     pub fn indirect_call(
         mut self,
@@ -1115,10 +1220,146 @@ impl<'a, 'func, 'module_env> Call<'a, 'func, 'module_env> {
         callee: ir::Value,
         call_args: &[ir::Value],
     ) -> WasmResult<Option<ir::Inst>> {
+        // If we are performing call-indirect caching with this table, check the cache.
+        let caching = if self.env.tunables.cache_call_indirects {
+            let plan = &self.env.module.table_plans[table_index];
+            // We can do the indirect call caching optimization only
+            // if table elements will not change (no opcodes exist
+            // that could write the table, and table not exported),
+            // and if we can use the zero-index as a sentinenl for "no
+            // cache entry" (initial zeroed vmctx state).
+            !plan.written && !plan.non_null_zero
+        } else {
+            false
+        };
+
+        // Allocate a call-indirect cache slot if caching is
+        // enabled. Note that this still may be `None` if we run out
+        // of slots.
+        let call_site = if caching {
+            self.env.alloc_call_indirect_index()
+        } else {
+            None
+        };
+
+        let (code_ptr, callee_vmctx) = if let Some(call_site) = call_site {
+            // Get a local copy of `vmctx`.
+            let vmctx = self.env.vmctx(self.builder.func);
+            let vmctx = self
+                .builder
+                .ins()
+                .global_value(self.env.pointer_type(), vmctx);
+
+            // Get the address of the cache slot in the VMContext
+            // struct.
+            let slot = self.call_indirect_cache_slot_addr(call_site, vmctx);
+
+            // Create the following CFG and generate code with the following outline:
+            //
+            //   (load cached index and code pointer)
+            //   hit = icmp eq (cached index), (callee)
+            //   brif hit, call_block((cached code ptr), vmctx), miss_block
+            //
+            // miss_block:
+            //   (compute actual code pointer, with checks)
+            //   same_instance = icmp eq (callee vmctx), (vmctx)
+            //   brif same_instance update_block, call_block((actual code ptr), (callee vmctx))
+            //
+            // update_block:
+            //   (store actual index and actual code pointer)
+            //   jump call_block((actual code ptr), (callee vmctx))
+            //
+            // call_block(code_ptr, callee_vmctx):
+            //   (unchecked call-indirect sequence)
+
+            // Create two-level conditionals with CFG.
+            let current_block = self.builder.current_block().unwrap();
+            let miss_block = self.builder.create_block();
+            let call_block = self.builder.create_block();
+            let update_block = self.builder.create_block();
+
+            self.builder.insert_block_after(miss_block, current_block);
+            self.builder.insert_block_after(update_block, miss_block);
+            self.builder.insert_block_after(call_block, update_block);
+            self.builder.set_cold_block(miss_block);
+            self.builder.set_cold_block(update_block);
+
+            // Load cached values, check for hit, branch to
+            // call block or miss block.
+
+            let (cached_index, cached_code_ptr) =
+                self.load_cached_indirect_index_and_code_ptr(slot);
+            let hit = self.builder.ins().icmp(IntCC::Equal, cached_index, callee);
+            self.builder
+                .ins()
+                .brif(hit, call_block, &[cached_code_ptr, vmctx], miss_block, &[]);
+
+            // Miss block: compute actual callee code pointer and
+            // vmctx, and update cache if same-instance.
+
+            self.builder.seal_block(miss_block);
+            self.builder.switch_to_block(miss_block);
+
+            if let Some((code_ptr, callee_vmctx)) =
+                self.check_and_load_code_and_callee_vmctx(table_index, ty_index, callee, true)?
+            {
+                // If callee vmctx is equal to ours, update the cache.
+                let same_instance = self.builder.ins().icmp(IntCC::Equal, callee_vmctx, vmctx);
+
+                self.builder.ins().brif(
+                    same_instance,
+                    update_block,
+                    &[],
+                    call_block,
+                    &[code_ptr, callee_vmctx],
+                );
+
+                self.builder.seal_block(update_block);
+                self.builder.switch_to_block(update_block);
+
+                self.store_cached_indirect_index_and_code_ptr(slot, callee, code_ptr);
+                self.builder
+                    .ins()
+                    .jump(call_block, &[code_ptr, callee_vmctx]);
+            }
+
+            // Call block: do the call.
+
+            self.builder.seal_block(call_block);
+            self.builder.switch_to_block(call_block);
+
+            let code_ptr = self
+                .builder
+                .append_block_param(call_block, self.env.pointer_type());
+            let callee_vmctx = self
+                .builder
+                .append_block_param(call_block, self.env.pointer_type());
+            (code_ptr, callee_vmctx)
+        } else {
+            match self.check_and_load_code_and_callee_vmctx(table_index, ty_index, callee, false)? {
+                Some(pair) => pair,
+                None => return Ok(None),
+            }
+        };
+
+        self.unchecked_call_impl(sig_ref, code_ptr, callee_vmctx, call_args)
+            .map(Some)
+    }
+
+    fn check_and_load_code_and_callee_vmctx(
+        &mut self,
+        table_index: TableIndex,
+        ty_index: TypeIndex,
+        callee: ir::Value,
+        cold_blocks: bool,
+    ) -> WasmResult<Option<(ir::Value, ir::Value)>> {
         // Get the funcref pointer from the table.
-        let funcref_ptr =
-            self.env
-                .get_or_init_func_ref_table_elem(self.builder, table_index, callee);
+        let funcref_ptr = self.env.get_or_init_func_ref_table_elem(
+            self.builder,
+            table_index,
+            callee,
+            cold_blocks,
+        );
 
         // If necessary, check the signature.
         let check = self.check_indirect_call_type_signature(table_index, ty_index, funcref_ptr);
@@ -1149,8 +1390,8 @@ impl<'a, 'func, 'module_env> Call<'a, 'func, 'module_env> {
             // is now unreachable code.
             CheckIndirectCallTypeSignature::StaticTrap => return Ok(None),
         };
-        self.unchecked_call(sig_ref, funcref_ptr, trap_code, call_args)
-            .map(Some)
+
+        Ok(Some(self.load_code_and_vmctx(funcref_ptr, trap_code)))
     }
 
     fn check_indirect_call_type_signature(
@@ -1307,6 +1548,15 @@ impl<'a, 'func, 'module_env> Call<'a, 'func, 'module_env> {
         callee_load_trap_code: Option<ir::TrapCode>,
         call_args: &[ir::Value],
     ) -> WasmResult<ir::Inst> {
+        let (func_addr, callee_vmctx) = self.load_code_and_vmctx(callee, callee_load_trap_code);
+        self.unchecked_call_impl(sig_ref, func_addr, callee_vmctx, call_args)
+    }
+
+    fn load_code_and_vmctx(
+        &mut self,
+        callee: ir::Value,
+        callee_load_trap_code: Option<ir::TrapCode>,
+    ) -> (ir::Value, ir::Value) {
         let pointer_type = self.env.pointer_type();
 
         // Dereference callee pointer to get the function address.
@@ -1323,7 +1573,26 @@ impl<'a, 'func, 'module_env> Call<'a, 'func, 'module_env> {
             callee,
             i32::from(self.env.offsets.ptr.vm_func_ref_wasm_call()),
         );
+        let callee_vmctx = self.builder.ins().load(
+            pointer_type,
+            mem_flags,
+            callee,
+            i32::from(self.env.offsets.ptr.vm_func_ref_vmctx()),
+        );
+
+        (func_addr, callee_vmctx)
+    }
 
+    /// This calls a function by reference without checking the
+    /// signature, given the raw code pointer to the
+    /// Wasm-calling-convention entry point and the callee vmctx.
+    fn unchecked_call_impl(
+        &mut self,
+        sig_ref: ir::SigRef,
+        func_addr: ir::Value,
+        callee_vmctx: ir::Value,
+        call_args: &[ir::Value],
+    ) -> WasmResult<ir::Inst> {
         let mut real_call_args = Vec::with_capacity(call_args.len() + 2);
         let caller_vmctx = self
             .builder
@@ -1331,14 +1600,8 @@ impl<'a, 'func, 'module_env> Call<'a, 'func, 'module_env> {
             .special_param(ArgumentPurpose::VMContext)
             .unwrap();
 
-        // First append the callee vmctx address.
-        let vmctx = self.builder.ins().load(
-            pointer_type,
-            mem_flags,
-            callee,
-            i32::from(self.env.offsets.ptr.vm_func_ref_vmctx()),
-        );
-        real_call_args.push(vmctx);
+        // First append the callee and caller vmctx addresses.
+        real_call_args.push(callee_vmctx);
         real_call_args.push(caller_vmctx);
 
         // Then append the regular call arguments.
@@ -1481,7 +1744,7 @@ impl<'module_environment> cranelift_wasm::FuncEnvironment for FuncEnvironment<'m
             // Function types.
             WasmHeapTopType::Func => match plan.style {
                 TableStyle::CallerChecksSignature => {
-                    Ok(self.get_or_init_func_ref_table_elem(builder, table_index, index))
+                    Ok(self.get_or_init_func_ref_table_elem(builder, table_index, index, false))
                 }
             },
         }
diff --git a/crates/environ/Cargo.toml b/crates/environ/Cargo.toml
index 7933a0399..a9d714c37 100644
--- a/crates/environ/Cargo.toml
+++ b/crates/environ/Cargo.toml
@@ -18,7 +18,7 @@ anyhow = { workspace = true, features = ['std'] }
 postcard = { workspace = true }
 cpp_demangle = { version = "0.4.3", optional = true }
 cranelift-entity = { workspace = true }
-wasmtime-types = { workspace = true }
+wasmtime-types = { workspace = true, features = ['std'] }
 wasmparser = { workspace = true }
 indexmap = { workspace = true, features = ["serde"] }
 thiserror = { workspace = true }
diff --git a/crates/environ/src/compile/module_environ.rs b/crates/environ/src/compile/module_environ.rs
index 7582f9c02..29f841193 100644
--- a/crates/environ/src/compile/module_environ.rs
+++ b/crates/environ/src/compile/module_environ.rs
@@ -15,6 +15,7 @@ use std::collections::HashMap;
 use std::mem;
 use std::path::PathBuf;
 use std::sync::Arc;
+use wasmparser::OperatorsReader;
 use wasmparser::{
     types::Types, CustomSectionReader, DataKind, ElementItems, ElementKind, Encoding, ExternalKind,
     FuncToValidate, FunctionBody, NameSectionReader, Naming, Operator, Parser, Payload, TypeRef,
@@ -114,6 +115,8 @@ pub struct FunctionBodyData<'a> {
     pub body: FunctionBody<'a>,
     /// Validator for the function body
     pub validator: FuncToValidate<ValidatorResources>,
+    /// The start index for call-indirects in this body.
+    pub call_indirect_start: usize,
 }
 
 #[derive(Debug, Default)]
@@ -432,6 +435,9 @@ impl<'a, 'data> ModuleEnvironment<'a, 'data> {
                         // this never gets past validation
                         ExternalKind::Tag => unreachable!(),
                     };
+                    if let EntityIndex::Table(table) = entity {
+                        self.flag_written_table(table);
+                    }
                     self.result
                         .module
                         .exports
@@ -497,6 +503,10 @@ impl<'a, 'data> ModuleEnvironment<'a, 'data> {
                             let (offset, escaped) = ConstExpr::from_wasmparser(offset_expr)?;
                             debug_assert!(escaped.is_empty());
 
+                            if !offset.provably_nonzero_i32() {
+                                self.flag_table_possibly_non_null_zero_element(table_index);
+                            }
+
                             self.result
                                 .module
                                 .table_initialization
@@ -536,6 +546,8 @@ impl<'a, 'data> ModuleEnvironment<'a, 'data> {
                     self.result.code_index + self.result.module.num_imported_funcs as u32;
                 let func_index = FuncIndex::from_u32(func_index);
 
+                let call_indirect_start = self.result.module.num_call_indirect_caches;
+
                 if self.tunables.generate_native_debuginfo {
                     let sig_index = self.result.module.functions[func_index].signature;
                     let sig = self.types[sig_index].unwrap_func();
@@ -554,10 +566,13 @@ impl<'a, 'data> ModuleEnvironment<'a, 'data> {
                             params: sig.params().into(),
                         });
                 }
+                self.prescan_code_section(body.get_operators_reader()?)?;
                 body.allow_memarg64(self.validator.features().contains(WasmFeatures::MEMORY64));
-                self.result
-                    .function_body_inputs
-                    .push(FunctionBodyData { validator, body });
+                self.result.function_body_inputs.push(FunctionBodyData {
+                    validator,
+                    body,
+                    call_indirect_start,
+                });
                 self.result.code_index += 1;
             }
 
@@ -689,6 +704,76 @@ and for re-adding support for interface types you can see this issue:
         Ok(())
     }
 
+    /// Check various properties in function bodies in a "pre-pass" as
+    /// needed, before we actually generate code. Currently this is
+    /// used for:
+    ///
+    /// - Call-indirect caching: we need to know whether a table is
+    ///   "immutable", i.e., there are opcodes that could update its
+    ///   entries. If this is the case then the optimization isn't
+    ///   applicable. We can check this by simply scanning all functions
+    ///   for the relevant opcodes.
+    ///
+    ///   We also need to know how many `call_indirect` opcodes are in
+    ///   the whole module so that we know how large a `vmctx` struct
+    ///   to reserve and what its layout will be; and the starting
+    ///   index in this count for each function, so we can generate
+    ///   its code (with accesses to its own `call_indirect` callsite
+    ///   caches) in parallel.
+    fn prescan_code_section(&mut self, reader: OperatorsReader<'data>) -> Result<()> {
+        if self.tunables.cache_call_indirects {
+            for op in reader {
+                let op = op?;
+                match op {
+                    // Check whether a table may be mutated by any
+                    // opcode. (Note that we separately check for
+                    // table exports so we can detect mutations from
+                    // the outside; here we are only concerned with
+                    // mutations by our own module's code.)
+                    Operator::TableSet { table }
+                    | Operator::TableFill { table }
+                    | Operator::TableInit { table, .. }
+                    | Operator::TableCopy {
+                        dst_table: table, ..
+                    } => {
+                        self.flag_written_table(TableIndex::from_u32(table));
+                    }
+                    // Count the `call_indirect` sites so we can
+                    // assign them unique slots.
+                    //
+                    // We record the value of this counter as a
+                    // start-index as we start to scan each function,
+                    // and that function's compilation (which is
+                    // normally a separate parallel task) counts on
+                    // its own from that start index.
+                    Operator::CallIndirect { .. } => {
+                        self.result.module.num_call_indirect_caches += 1;
+
+                        // Cap the `num_call_indirect_caches` counter
+                        // at `max_call_indirect_cache_slots` so that
+                        // we don't allocate more than that amount of
+                        // space in the VMContext struct.
+                        //
+                        // Note that we also separately check against
+                        // this limit when emitting code for each
+                        // individual slot because we may cross the
+                        // limit in the middle of a function; also
+                        // once we hit the limit, the start-index for
+                        // each subsequent function will be saturated
+                        // at the limit.
+                        self.result.module.num_call_indirect_caches = core::cmp::min(
+                            self.result.module.num_call_indirect_caches,
+                            self.tunables.max_call_indirect_cache_slots,
+                        );
+                    }
+
+                    _ => {}
+                }
+            }
+        }
+        Ok(())
+    }
+
     fn register_dwarf_section(&mut self, section: &CustomSectionReader<'data>) {
         let name = section.name().trim_end_matches(".dwo");
         if !name.starts_with(".debug_") {
@@ -801,6 +886,14 @@ and for re-adding support for interface types you can see this issue:
         self.result.module.num_escaped_funcs += 1;
     }
 
+    fn flag_written_table(&mut self, table: TableIndex) {
+        self.result.module.table_plans[table].written = true;
+    }
+
+    fn flag_table_possibly_non_null_zero_element(&mut self, table: TableIndex) {
+        self.result.module.table_plans[table].non_null_zero = true;
+    }
+
     /// Parses the Name section of the wasm module.
     fn name_section(&mut self, names: NameSectionReader<'data>) -> WasmResult<()> {
         for subsection in names {
diff --git a/crates/environ/src/module.rs b/crates/environ/src/module.rs
index 7d3bede30..b60cc9775 100644
--- a/crates/environ/src/module.rs
+++ b/crates/environ/src/module.rs
@@ -371,13 +371,24 @@ pub struct TablePlan {
     pub table: Table,
     /// Our chosen implementation style.
     pub style: TableStyle,
+    /// Whether the table is observed to be written or possibly
+    /// written: either by some opcode present in the code section, or
+    /// by the fact that the table is exported.
+    pub written: bool,
+    /// Whether this table may have a non-null zero element.
+    pub non_null_zero: bool,
 }
 
 impl TablePlan {
     /// Draw up a plan for implementing a `Table`.
     pub fn for_table(table: Table, tunables: &Tunables) -> Self {
         let style = TableStyle::for_table(table, tunables);
-        Self { table, style }
+        Self {
+            table,
+            style,
+            written: false,
+            non_null_zero: false,
+        }
     }
 }
 
@@ -507,6 +518,9 @@ pub struct Module {
     /// an `func_ref` index (and is the maximum func_ref index).
     pub num_escaped_funcs: usize,
 
+    /// Number of call-indirect caches.
+    pub num_call_indirect_caches: usize,
+
     /// Types of functions, imported and local.
     pub functions: PrimaryMap<FuncIndex, FunctionType>,
 
diff --git a/crates/environ/src/tunables.rs b/crates/environ/src/tunables.rs
index fc932212f..440c593e9 100644
--- a/crates/environ/src/tunables.rs
+++ b/crates/environ/src/tunables.rs
@@ -59,6 +59,14 @@ pub struct Tunables {
 
     /// Whether or not Wasm functions target the winch abi.
     pub winch_callable: bool,
+
+    /// Whether we implement a one-entry cache at each call_indirect
+    /// site.
+    pub cache_call_indirects: bool,
+
+    /// The maximum number of call-indirect cache slots that we will
+    /// allocate for one instance.
+    pub max_call_indirect_cache_slots: usize,
 }
 
 impl Tunables {
@@ -110,6 +118,8 @@ impl Tunables {
             relaxed_simd_deterministic: false,
             tail_callable: false,
             winch_callable: false,
+            cache_call_indirects: false,
+            max_call_indirect_cache_slots: 50_000,
         }
     }
 
diff --git a/crates/environ/src/vmoffsets.rs b/crates/environ/src/vmoffsets.rs
index 4678e56f0..166de6503 100644
--- a/crates/environ/src/vmoffsets.rs
+++ b/crates/environ/src/vmoffsets.rs
@@ -23,11 +23,12 @@
 //      owned_memories: [VMMemoryDefinition; module.num_owned_memories],
 //      globals: [VMGlobalDefinition; module.num_defined_globals],
 //      func_refs: [VMFuncRef; module.num_escaped_funcs],
+//      call_indirect_caches: [VMCallIndirectCache; module.num_call_indirect_caches],
 // }
 
 use crate::{
-    DefinedGlobalIndex, DefinedMemoryIndex, DefinedTableIndex, FuncIndex, FuncRefIndex,
-    GlobalIndex, MemoryIndex, Module, TableIndex,
+    CallIndirectSiteIndex, DefinedGlobalIndex, DefinedMemoryIndex, DefinedTableIndex, FuncIndex,
+    FuncRefIndex, GlobalIndex, MemoryIndex, Module, TableIndex,
 };
 use cranelift_entity::packed_option::ReservedValue;
 use wasmtime_types::OwnedMemoryIndex;
@@ -72,6 +73,8 @@ pub struct VMOffsets<P> {
     /// The number of escaped functions in the module, the size of the func_refs
     /// array.
     pub num_escaped_funcs: u32,
+    /// The number of call_indirect cache entries in the cache array.
+    pub num_call_indirect_caches: u32,
 
     // precalculated offsets of various member fields
     magic: u32,
@@ -93,6 +96,7 @@ pub struct VMOffsets<P> {
     owned_memories: u32,
     defined_globals: u32,
     defined_func_refs: u32,
+    call_indirect_caches: u32,
     size: u32,
 }
 
@@ -231,6 +235,23 @@ pub trait PtrSize {
         ))
         .unwrap()
     }
+
+    // Offsets within `VMCallIndirectCache`.
+
+    /// Return the offset of `VMCallIndirectCache::wasm_call`.
+    fn vmcall_indirect_cache_wasm_call(&self) -> u8 {
+        0
+    }
+
+    /// Return the offset of `VMCallIndirectCache::index`.
+    fn vmcall_indirect_cache_index(&self) -> u8 {
+        self.size()
+    }
+
+    /// Return the size of a `VMCallIndirectCache`.
+    fn size_of_vmcall_indirect_cache(&self) -> u8 {
+        2 * self.size()
+    }
 }
 
 /// Type representing the size of a pointer for the current compilation host
@@ -274,6 +295,8 @@ pub struct VMOffsetsFields<P> {
     /// The number of escaped functions in the module, the size of the function
     /// references array.
     pub num_escaped_funcs: u32,
+    /// The number of call_indirect cache entries in the cache array.
+    pub num_call_indirect_caches: u32,
 }
 
 impl<P: PtrSize> VMOffsets<P> {
@@ -300,6 +323,7 @@ impl<P: PtrSize> VMOffsets<P> {
             num_owned_memories,
             num_defined_globals: cast_to_u32(module.globals.len() - module.num_imported_globals),
             num_escaped_funcs: cast_to_u32(module.num_escaped_funcs),
+            num_call_indirect_caches: cast_to_u32(module.num_call_indirect_caches),
         })
     }
 
@@ -329,6 +353,7 @@ impl<P: PtrSize> VMOffsets<P> {
                     num_defined_memories: _,
                     num_owned_memories: _,
                     num_escaped_funcs: _,
+                    num_call_indirect_caches: _,
 
                     // used as the initial size below
                     size,
@@ -354,6 +379,7 @@ impl<P: PtrSize> VMOffsets<P> {
         }
 
         calculate_sizes! {
+            call_indirect_caches: "call_indirect caches",
             defined_func_refs: "module functions",
             defined_globals: "defined globals",
             owned_memories: "owned memories",
@@ -390,6 +416,7 @@ impl<P: PtrSize> From<VMOffsetsFields<P>> for VMOffsets<P> {
             num_owned_memories: fields.num_owned_memories,
             num_defined_globals: fields.num_defined_globals,
             num_escaped_funcs: fields.num_escaped_funcs,
+            num_call_indirect_caches: fields.num_call_indirect_caches,
             magic: 0,
             runtime_limits: 0,
             builtin_functions: 0,
@@ -409,6 +436,7 @@ impl<P: PtrSize> From<VMOffsetsFields<P>> for VMOffsets<P> {
             owned_memories: 0,
             defined_globals: 0,
             defined_func_refs: 0,
+            call_indirect_caches: 0,
             size: 0,
         };
 
@@ -474,6 +502,10 @@ impl<P: PtrSize> From<VMOffsetsFields<P>> for VMOffsets<P> {
                 ret.num_escaped_funcs,
                 ret.ptr.size_of_vm_func_ref(),
             ),
+            size(call_indirect_caches) = cmul(
+                ret.num_call_indirect_caches,
+                ret.ptr.size_of_vmcall_indirect_cache(),
+            ),
         }
 
         ret.size = next_field_offset;
@@ -738,6 +770,12 @@ impl<P: PtrSize> VMOffsets<P> {
         self.builtin_functions
     }
 
+    /// The offset of the `call_indirect_caches` array.
+    #[inline]
+    pub fn vmctx_call_indirec_caches_begin(&self) -> u32 {
+        self.call_indirect_caches
+    }
+
     /// Return the size of the `VMContext` allocation.
     #[inline]
     pub fn size_of_vmctx(&self) -> u32 {
@@ -888,6 +926,31 @@ impl<P: PtrSize> VMOffsets<P> {
     pub fn vmctx_vmglobal_import_from(&self, index: GlobalIndex) -> u32 {
         self.vmctx_vmglobal_import(index) + u32::from(self.vmglobal_import_from())
     }
+
+    /// Return the offset to the `VMCallIndirectCache` for the given
+    /// call-indirect site.
+    #[inline]
+    pub fn vmctx_call_indirect_cache(&self, call_site: CallIndirectSiteIndex) -> u32 {
+        assert!(call_site.as_u32() < self.num_call_indirect_caches);
+        self.vmctx_call_indirec_caches_begin()
+            + call_site.as_u32() * u32::from(self.ptr.size_of_vmcall_indirect_cache())
+    }
+
+    /// Return the offset to the `wasm_call` field in `*const
+    /// VMCallIndirectCache` with call-site ID `call_site`.
+    #[inline]
+    pub fn vmctx_call_indirect_cache_wasm_call(&self, call_site: CallIndirectSiteIndex) -> u32 {
+        self.vmctx_call_indirect_cache(call_site)
+            + u32::from(self.ptr.vmcall_indirect_cache_wasm_call())
+    }
+
+    /// Return the offset to the `index` field in `*const
+    /// VMCallIndirectCache` with call-site ID `call_site`.
+    #[inline]
+    pub fn vmctx_call_indirect_cache_index(&self, call_site: CallIndirectSiteIndex) -> u32 {
+        self.vmctx_call_indirect_cache(call_site)
+            + u32::from(self.ptr.vmcall_indirect_cache_index())
+    }
 }
 
 /// Offsets for `VMDrcHeader`.
diff --git a/crates/fuzzing/src/generators/config.rs b/crates/fuzzing/src/generators/config.rs
index 611ae3088..42d4531e9 100644
--- a/crates/fuzzing/src/generators/config.rs
+++ b/crates/fuzzing/src/generators/config.rs
@@ -173,7 +173,9 @@ impl Config {
                 self.wasmtime.memory_guaranteed_dense_image_size,
             ))
             .allocation_strategy(self.wasmtime.strategy.to_wasmtime())
-            .generate_address_map(self.wasmtime.generate_address_map);
+            .generate_address_map(self.wasmtime.generate_address_map)
+            .cache_call_indirects(self.wasmtime.cache_call_indirects)
+            .max_call_indirect_cache_slots(self.wasmtime.max_call_indirect_cache_slots);
 
         if !self.module_config.config.simd_enabled {
             cfg.wasm_relaxed_simd(false);
@@ -476,6 +478,10 @@ pub struct WasmtimeConfig {
     native_unwind_info: bool,
     /// Configuration for the compiler to use.
     pub compiler_strategy: CompilerStrategy,
+    /// Whether we enable indirect-call caching.
+    cache_call_indirects: bool,
+    /// The maximum number of call-indirect cache slots.
+    max_call_indirect_cache_slots: usize,
 
     /// Whether or not fuzzing should enable PCC.
     pcc: bool,
diff --git a/crates/types/Cargo.toml b/crates/types/Cargo.toml
index 737a4588c..411860d8e 100644
--- a/crates/types/Cargo.toml
+++ b/crates/types/Cargo.toml
@@ -13,8 +13,10 @@ cranelift-entity = { workspace = true, features = ['enable-serde'] }
 serde = { workspace = true }
 serde_derive = { workspace = true }
 smallvec = { workspace = true, features = ["serde"] }
-thiserror = { workspace = true }
 wasmparser = { workspace = true }
 
 [lints]
 workspace = true
+
+[features]
+std = ['wasmparser/std']
diff --git a/crates/types/src/error.rs b/crates/types/src/error.rs
index f88536fa5..37e5043e0 100644
--- a/crates/types/src/error.rs
+++ b/crates/types/src/error.rs
@@ -1,16 +1,16 @@
-use thiserror::Error;
+use alloc::string::String;
+use core::fmt;
 
 /// A WebAssembly translation error.
 ///
 /// When a WebAssembly function can't be translated, one of these error codes will be returned
 /// to describe the failure.
-#[derive(Error, Debug)]
+#[derive(Debug)]
 pub enum WasmError {
     /// The input WebAssembly code is invalid.
     ///
     /// This error code is used by a WebAssembly translator when it encounters invalid WebAssembly
     /// code. This should never happen for validated WebAssembly code.
-    #[error("Invalid input WebAssembly code at offset {offset}: {message}")]
     InvalidWebAssembly {
         /// A string describing the validation error.
         message: String,
@@ -21,7 +21,6 @@ pub enum WasmError {
     /// A feature used by the WebAssembly code is not supported by the embedding environment.
     ///
     /// Embedding environments may have their own limitations and feature restrictions.
-    #[error("Unsupported feature: {0}")]
     Unsupported(String),
 
     /// An implementation limit was exceeded.
@@ -30,11 +29,9 @@ pub enum WasmError {
     /// limits][limits] that cause compilation to fail when they are exceeded.
     ///
     /// [limits]: https://github.com/bytecodealliance/wasmtime/blob/main/cranelift/docs/ir.md#implementation-limits
-    #[error("Implementation limit exceeded")]
     ImplLimitExceeded,
 
     /// Any user-defined error.
-    #[error("User error: {0}")]
     User(String),
 }
 
@@ -42,7 +39,7 @@ pub enum WasmError {
 /// on the arguments to this macro.
 #[macro_export]
 macro_rules! wasm_unsupported {
-    ($($arg:tt)*) => { $crate::WasmError::Unsupported(format!($($arg)*)) }
+    ($($arg:tt)*) => { $crate::WasmError::Unsupported($crate::__format!($($arg)*)) }
 }
 
 impl From<wasmparser::BinaryReaderError> for WasmError {
@@ -57,3 +54,28 @@ impl From<wasmparser::BinaryReaderError> for WasmError {
 
 /// A convenient alias for a `Result` that uses `WasmError` as the error type.
 pub type WasmResult<T> = Result<T, WasmError>;
+
+impl fmt::Display for WasmError {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match self {
+            WasmError::InvalidWebAssembly { message, offset } => {
+                write!(
+                    f,
+                    "Invalid input WebAssembly code at offset {offset}: {message}"
+                )
+            }
+            WasmError::Unsupported(s) => {
+                write!(f, "Unsupported feature: {s}")
+            }
+            WasmError::ImplLimitExceeded => {
+                write!(f, "Implementation limit exceeded")
+            }
+            WasmError::User(s) => {
+                write!(f, "User error: {s}")
+            }
+        }
+    }
+}
+
+#[cfg(feature = "std")]
+impl std::error::Error for WasmError {}
diff --git a/crates/types/src/lib.rs b/crates/types/src/lib.rs
index 2ffe76d01..045442ac2 100644
--- a/crates/types/src/lib.rs
+++ b/crates/types/src/lib.rs
@@ -1,12 +1,22 @@
 //! Internal dependency of Wasmtime and Cranelift that defines types for
 //! WebAssembly.
 
-use smallvec::SmallVec;
+#![no_std]
+
+extern crate alloc;
+#[cfg(feature = "std")]
+extern crate std;
+
 pub use wasmparser;
 
+#[doc(hidden)]
+pub use alloc::format as __format;
+
+use alloc::boxed::Box;
+use core::{fmt, ops::Range};
 use cranelift_entity::entity_impl;
 use serde_derive::{Deserialize, Serialize};
-use std::{fmt, ops::Range};
+use smallvec::SmallVec;
 
 mod error;
 pub use error::*;
@@ -973,6 +983,12 @@ entity_impl!(TagIndex);
 pub struct StaticModuleIndex(u32);
 entity_impl!(StaticModuleIndex);
 
+/// Index of a `call_indirect` instruction in a module, used for
+/// caching that callsite's target in the VMContext.
+#[derive(Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Debug, Serialize, Deserialize)]
+pub struct CallIndirectSiteIndex(u32);
+entity_impl!(CallIndirectSiteIndex);
+
 /// An index of an entity.
 #[derive(Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Debug, Serialize, Deserialize)]
 pub enum EntityIndex {
@@ -1194,6 +1210,34 @@ impl ConstExpr {
     pub fn ops(&self) -> &[ConstOp] {
         &self.ops
     }
+
+    /// Is this ConstExpr a provably nonzero integer value?
+    ///
+    /// This must be conservative: if the expression *might* be zero,
+    /// it must return `false`. It is always allowed to return `false`
+    /// for some expression kind that we don't support. However, if it
+    /// returns `true`, the expression must be actually nonzero.
+    ///
+    /// We use this for certain table optimizations that rely on
+    /// knowing for sure that index 0 is not referenced.
+    pub fn provably_nonzero_i32(&self) -> bool {
+        assert!(self.ops.len() > 0);
+        if self.ops.len() > 1 {
+            // Compound expressions not yet supported: conservatively
+            // return `false` (we can't prove nonzero).
+            return false;
+        }
+        // Exactly one op at this point.
+        match self.ops[0] {
+            // An actual zero value -- definitely not nonzero!
+            ConstOp::I32Const(0) => false,
+            // Any other constant value -- provably nonzero, if above
+            // did not match.
+            ConstOp::I32Const(_) => true,
+            // Anything else: we can't prove anything.
+            _ => false,
+        }
+    }
 }
 
 /// The subset of Wasm opcodes that are constant.
diff --git a/crates/wasi-preview1-component-adapter/verify/Cargo.toml b/crates/wasi-preview1-component-adapter/verify/Cargo.toml
index 27234761a..093ce0678 100644
--- a/crates/wasi-preview1-component-adapter/verify/Cargo.toml
+++ b/crates/wasi-preview1-component-adapter/verify/Cargo.toml
@@ -9,6 +9,6 @@ publish = false
 workspace = true
 
 [dependencies]
-wasmparser = { workspace = true }
+wasmparser = { workspace = true, features = ['std'] }
 wat = { workspace = true }
 anyhow = { workspace = true, features = ['std'] }
diff --git a/crates/wasmtime/src/config.rs b/crates/wasmtime/src/config.rs
index 84ae3cc12..1e6a49835 100644
--- a/crates/wasmtime/src/config.rs
+++ b/crates/wasmtime/src/config.rs
@@ -151,6 +151,8 @@ struct ConfigTunables {
     debug_adapter_modules: Option<bool>,
     relaxed_simd_deterministic: Option<bool>,
     tail_callable: Option<bool>,
+    cache_call_indirects: Option<bool>,
+    max_call_indirect_cache_slots: Option<usize>,
 }
 
 /// User-provided configuration for the compiler.
@@ -949,6 +951,57 @@ impl Config {
         self
     }
 
+    /// Configures whether we enable the "indirect call cache" optimization.
+    ///
+    /// This feature adds, for each `call_indirect` instruction in a
+    /// Wasm module (i.e., a function-pointer call in guest code), a
+    /// one-entry cache that speeds up the translation from a table
+    /// index to the actual machine code. By default, the VM's
+    /// implementation of this translation requires several
+    /// indirections and checks (table bounds-check, function
+    /// signature-check, table lazy-initialization logic). The intent
+    /// of this feature is to speed up indirect calls substantially
+    /// when they are repeated frequently in hot code.
+    ///
+    /// While it accelerates repeated calls, this feature has the
+    /// potential to slow down instantiation slightly, because it adds
+    /// additional state (the cache storage -- usually 16 bytes per
+    /// `call_indirect` instruction for each instance) that has to be
+    /// initialized. In practice, we have not seen
+    /// measurable/statistically-significant impact from this, though.
+    ///
+    /// Until we have further experience with this feature, it will
+    /// remain off: it is `false` by default.
+    pub fn cache_call_indirects(&mut self, enable: bool) -> &mut Self {
+        self.tunables.cache_call_indirects = Some(enable);
+        self
+    }
+
+    /// Configures the "indirect call cache" maximum capacity.
+    ///
+    /// If the [`Config::cache_call_indirects`] configuration option
+    /// is enabled, the engine allocates "cache slots" directly in its
+    /// per-instance state struct for each `call_indirect` in the
+    /// module's code. We place a limit on this count in order to
+    /// avoid inflating the state too much with very large modules. If
+    /// a module exceeds the limit, the first `max` indirect
+    /// call-sites will still have a one-entry cache, but any indirect
+    /// call-sites beyond the limit (in linear order in the module's
+    /// code section) do not participate in the caching, as if the
+    /// option were turned off.
+    ///
+    /// There is also an internal hard cap to this limit:
+    /// configurations with `max` beyond `50_000` will effectively cap
+    /// the limit at `50_000`. This is so that instance state does not
+    /// become unreasonably large.
+    ///
+    /// This is `50_000` by default.
+    pub fn max_call_indirect_cache_slots(&mut self, max: usize) -> &mut Self {
+        const HARD_CAP: usize = 50_000; // See doc-comment above.
+        self.tunables.max_call_indirect_cache_slots = Some(core::cmp::min(max, HARD_CAP));
+        self
+    }
+
     /// Configures which compilation strategy will be used for wasm modules.
     ///
     /// This method can be used to configure which compiler is used for wasm
@@ -1745,6 +1798,8 @@ impl Config {
             debug_adapter_modules
             relaxed_simd_deterministic
             tail_callable
+            cache_call_indirects
+            max_call_indirect_cache_slots
         }
 
         // If we're going to compile with winch, we must use the winch calling convention.
diff --git a/crates/wasmtime/src/engine/serialization.rs b/crates/wasmtime/src/engine/serialization.rs
index 4aca40bc1..78f02229d 100644
--- a/crates/wasmtime/src/engine/serialization.rs
+++ b/crates/wasmtime/src/engine/serialization.rs
@@ -351,6 +351,8 @@ impl Metadata<'_> {
             relaxed_simd_deterministic,
             tail_callable,
             winch_callable,
+            cache_call_indirects,
+            max_call_indirect_cache_slots,
 
             // This doesn't affect compilation, it's just a runtime setting.
             dynamic_memory_growth_reserve: _,
@@ -417,6 +419,16 @@ impl Metadata<'_> {
             other.winch_callable,
             "Winch calling convention",
         )?;
+        Self::check_bool(
+            cache_call_indirects,
+            other.cache_call_indirects,
+            "caching of call-indirect targets",
+        )?;
+        Self::check_int(
+            max_call_indirect_cache_slots,
+            other.max_call_indirect_cache_slots,
+            "maximum slot count for caching of call-indirect targets",
+        )?;
 
         Ok(())
     }
diff --git a/crates/wasmtime/src/runtime/vm/gc/enabled/drc.rs b/crates/wasmtime/src/runtime/vm/gc/enabled/drc.rs
index a43a2f955..ad79bd231 100644
--- a/crates/wasmtime/src/runtime/vm/gc/enabled/drc.rs
+++ b/crates/wasmtime/src/runtime/vm/gc/enabled/drc.rs
@@ -899,6 +899,7 @@ mod tests {
             num_owned_memories: 0,
             num_defined_globals: 0,
             num_escaped_funcs: 0,
+            num_call_indirect_caches: 0,
         });
 
         assert_eq!(
@@ -927,6 +928,7 @@ mod tests {
             num_owned_memories: 0,
             num_defined_globals: 0,
             num_escaped_funcs: 0,
+            num_call_indirect_caches: 0,
         });
         assert_eq!(
             offsets.vm_gc_ref_activation_table_next() as usize,
@@ -954,6 +956,7 @@ mod tests {
             num_owned_memories: 0,
             num_defined_globals: 0,
             num_escaped_funcs: 0,
+            num_call_indirect_caches: 0,
         });
         assert_eq!(
             offsets.vm_gc_ref_activation_table_end() as usize,
diff --git a/crates/wasmtime/src/runtime/vm/vmcontext.rs b/crates/wasmtime/src/runtime/vm/vmcontext.rs
index 04ce80439..f9733ba46 100644
--- a/crates/wasmtime/src/runtime/vm/vmcontext.rs
+++ b/crates/wasmtime/src/runtime/vm/vmcontext.rs
@@ -907,6 +907,53 @@ mod test_vmruntime_limits {
     }
 }
 
+/// One call-indirect cache entry.
+///
+/// It consists of the last observed function-pointer index, and the
+/// direct code pointer (with the same vmctx, i.e., in the same
+/// instance) to call if this index matches.
+#[derive(Debug, Clone)]
+#[allow(dead_code)] // not actually used in Rust runtime code; only in generated code.
+#[repr(C)]
+pub struct VMCallIndirectCache {
+    /// Function pointer for this funcref if being called via the Wasm
+    /// calling convention.
+    pub wasm_call: NonNull<VMWasmCallFunction>,
+
+    /// Table index corresponding to the above function pointer.
+    pub index: usize,
+    // If more elements are added here, remember to add offset_of tests below!
+}
+
+unsafe impl Send for VMCallIndirectCache {}
+unsafe impl Sync for VMCallIndirectCache {}
+
+#[cfg(test)]
+mod test_vm_call_indirect_cache {
+    use super::VMCallIndirectCache;
+    use memoffset::offset_of;
+    use std::mem::size_of;
+    use wasmtime_environ::{Module, PtrSize, VMOffsets};
+
+    #[test]
+    fn check_vm_call_indirect_cache_offsets() {
+        let module = Module::new();
+        let offsets = VMOffsets::new(size_of::<*mut u8>() as u8, &module);
+        assert_eq!(
+            size_of::<VMCallIndirectCache>(),
+            usize::from(offsets.ptr.size_of_vmcall_indirect_cache())
+        );
+        assert_eq!(
+            offset_of!(VMCallIndirectCache, wasm_call),
+            usize::from(offsets.ptr.vmcall_indirect_cache_wasm_call())
+        );
+        assert_eq!(
+            offset_of!(VMCallIndirectCache, index),
+            usize::from(offsets.ptr.vmcall_indirect_cache_index())
+        );
+    }
+}
+
 /// The VM "context", which is pointed to by the `vmctx` arg in Cranelift.
 /// This has information about globals, memories, tables, and other runtime
 /// state associated with the current instance.
diff --git a/crates/winch/src/compiler.rs b/crates/winch/src/compiler.rs
index 217f3daff..8ecb1b8fa 100644
--- a/crates/winch/src/compiler.rs
+++ b/crates/winch/src/compiler.rs
@@ -95,7 +95,9 @@ impl wasmtime_environ::Compiler for Compiler {
         let index = translation.module.func_index(index);
         let sig = translation.module.functions[index].signature;
         let ty = types[sig].unwrap_func();
-        let FunctionBodyData { body, validator } = data;
+        let FunctionBodyData {
+            body, validator, ..
+        } = data;
         let mut context = self.get_context(translation);
         let mut validator = validator.into_validator(mem::take(&mut context.allocations));
         let func = self


Test patch:
diff --git a/cranelift/filetests/filetests/egraph/associative-and-commutative.clif b/cranelift/filetests/filetests/egraph/associative-and-commutative.clif
index ab4b04914..dd1d8567f 100644
--- a/cranelift/filetests/filetests/egraph/associative-and-commutative.clif
+++ b/cranelift/filetests/filetests/egraph/associative-and-commutative.clif
@@ -117,3 +117,171 @@ block0(v0: i8):
     v28 = ireduce.i16 v27
     return v28
 }
+
+function %iadd_sub_1_shallow_and_wide(i32, i32, i32, i32) -> i32 {
+block0(v0: i32, v1: i32, v2: i32, v3: i32):
+    v4 = isub v2, v3
+    v5 = isub v1, v4
+    v6 = isub v0, v5
+    return v6
+; check:  v7 = isub v0, v1
+; nextln: v4 = isub v2, v3
+; nextln: v8 = iadd v7, v4
+; check: return v8
+}
+
+function %iadd_sub_2_shallow_and_wide(i32, i32, i32, i32) -> i32 {
+block0(v0: i32, v1: i32, v2: i32, v3: i32):
+    v4 = iadd v2, v3
+    v5 = isub v1, v4
+    v6 = isub v0, v5
+    return v6
+; check:  v7 = isub v0, v1
+; nextln: v4 = iadd v2, v3
+; nextln: v8 = iadd v7, v4
+; check: return v8
+}
+
+function %iadd_sub_3_shallow_and_wide(i32, i32, i32, i32) -> i32 {
+block0(v0: i32, v1: i32, v2: i32, v3: i32):
+    v4 = isub v2, v3
+    v5 = iadd v1, v4
+    v6 = isub v0, v5
+    return v6
+; check:  v7 = isub v0, v1
+; nextln: v4 = isub v2, v3
+; nextln: v8 = isub v7, v4
+; check: return v8
+}
+
+function %iadd_sub_4_shallow_and_wide(i32, i32, i32, i32) -> i32 {
+block0(v0: i32, v1: i32, v2: i32, v3: i32):
+    v4 = iadd v2, v3
+    v5 = iadd v1, v4
+    v6 = isub v0, v5
+    return v6
+; check:  v7 = isub v0, v1
+; nextln: v4 = iadd v2, v3
+; nextln: v8 = isub v7, v4
+; check: return v8
+}
+
+function %iadd_sub_5_shallow_and_wide(i32, i32, i32, i32) -> i32 {
+block0(v0: i32, v1: i32, v2: i32, v3: i32):
+    v4 = isub v2, v3
+    v5 = isub v1, v4
+    v6 = iadd v0, v5
+    return v6
+; check:  v7 = iadd v0, v1
+; nextln: v4 = isub v2, v3
+; nextln: v8 = isub v7, v4
+; check: return v8
+}
+
+function %iadd_sub_6_shallow_and_wide(i32, i32, i32, i32) -> i32 {
+block0(v0: i32, v1: i32, v2: i32, v3: i32):
+    v4 = iadd v2, v3
+    v5 = isub v1, v4
+    v6 = iadd v0, v5
+    return v6
+; check:  v7 = iadd v0, v1
+; nextln: v4 = iadd v2, v3
+; nextln: v8 = isub v7, v4
+; check: return v8
+}
+
+function %iadd_sub_7_shallow_and_wide(i32, i32, i32, i32) -> i32 {
+block0(v0: i32, v1: i32, v2: i32, v3: i32):
+    v4 = isub v2, v3
+    v5 = iadd v1, v4
+    v6 = iadd v0, v5
+    return v6
+; check:  v7 = iadd v0, v1
+; nextln: v4 = isub v2, v3
+; nextln: v8 = iadd v7, v4
+; check: return v8
+}
+
+function %isub_add_1_shallow_and_wide(i32, i32, i32, i32) -> i32 {
+block0(v0: i32, v1: i32, v2: i32, v3: i32):
+    v4 = isub v0, v1
+    v5 = isub v4, v2
+    v6 = isub v5, v3
+    return v6
+; check:  v4 = isub v0, v1
+; nextln: v7 = iadd v2, v3
+; nextln: v8 = isub v4, v7
+; check: return v8
+}
+
+function %isub_add_2_shallow_and_wide(i32, i32, i32, i32) -> i32 {
+block0(v0: i32, v1: i32, v2: i32, v3: i32):
+    v4 = isub v0, v1
+    v5 = isub v4, v2
+    v6 = iadd v5, v3
+    return v6
+; check:  v4 = isub v0, v1
+; nextln: v7 = isub v2, v3
+; nextln: v8 = isub v4, v7
+; check: return v8
+}
+
+function %isub_add_3_shallow_and_wide(i32, i32, i32, i32) -> i32 {
+block0(v0: i32, v1: i32, v2: i32, v3: i32):
+    v4 = isub v0, v1
+    v5 = iadd v4, v2
+    v6 = isub v5, v3
+    return v6
+; check:  v4 = isub v0, v1
+; nextln: v7 = isub v2, v3
+; nextln: v8 = iadd v4, v7
+; check: return v8
+}
+
+function %isub_add_4_shallow_and_wide(i32, i32, i32, i32) -> i32 {
+block0(v0: i32, v1: i32, v2: i32, v3: i32):
+    v4 = isub v0, v1
+    v5 = iadd v4, v2
+    v6 = iadd v5, v3
+    return v6
+; check:  v4 = isub v0, v1
+; nextln: v7 = iadd v2, v3
+; nextln: v8 = iadd v4, v7
+; check: return v8
+}
+
+function %isub_add_5_shallow_and_wide(i32, i32, i32, i32) -> i32 {
+block0(v0: i32, v1: i32, v2: i32, v3: i32):
+    v4 = iadd v0, v1
+    v5 = isub v4, v2
+    v6 = isub v5, v3
+    return v6
+; check:  v4 = iadd v0, v1
+; nextln: v7 = iadd v2, v3
+; nextln: v8 = isub v4, v7
+; check: return v8
+}
+
+function %isub_add_6_shallow_and_wide(i32, i32, i32, i32) -> i32 {
+block0(v0: i32, v1: i32, v2: i32, v3: i32):
+    v4 = iadd v0, v1
+    v5 = isub v4, v2
+    v6 = iadd v5, v3
+    return v6
+; check:  v4 = iadd v0, v1
+; nextln: v7 = isub v2, v3
+; nextln: v8 = isub v4, v7
+; check: return v8
+}
+
+function %isub_add_7_shallow_and_wide(i32, i32, i32, i32) -> i32 {
+block0(v0: i32, v1: i32, v2: i32, v3: i32):
+    v4 = iadd v0, v1
+    v5 = iadd v4, v2
+    v6 = isub v5, v3
+    return v6
+; check:  v4 = iadd v0, v1
+; nextln: v7 = isub v2, v3
+; nextln: v8 = iadd v4, v7
+; check: return v8
+}
\ No newline at end of file
diff --git a/crates/test-programs/src/bin/nn_image_classification.rs b/crates/test-programs/src/bin/nn_image_classification.rs
index f81b89154..af2b40c1f 100644
--- a/crates/test-programs/src/bin/nn_image_classification.rs
+++ b/crates/test-programs/src/bin/nn_image_classification.rs
@@ -1,59 +1,19 @@
 use anyhow::Result;
 use std::fs;
-use wasi_nn::*;
+use test_programs::nn::{classify, sort_results};
+use wasi_nn::{ExecutionTarget, GraphBuilder, GraphEncoding};
 
 pub fn main() -> Result<()> {
-    let xml = fs::read_to_string("fixture/model.xml").unwrap();
-    println!("Read graph XML, first 50 characters: {}", &xml[..50]);
-
-    let weights = fs::read("fixture/model.bin").unwrap();
-    println!("Read graph weights, size in bytes: {}", weights.len());
-
+    let xml = fs::read("fixture/model.xml")
+        .expect("the model file to be mapped to the fixture directory");
+    let weights = fs::read("fixture/model.bin")
+        .expect("the weights file to be mapped to the fixture directory");
     let graph = GraphBuilder::new(GraphEncoding::Openvino, ExecutionTarget::CPU)
-        .build_from_bytes([&xml.into_bytes(), &weights])?;
-    println!("Loaded graph into wasi-nn with ID: {}", graph);
-
-    let mut context = graph.init_execution_context()?;
-    println!("Created wasi-nn execution context with ID: {}", context);
-
-    // Load a tensor that precisely matches the graph input tensor (see
-    // `fixture/frozen_inference_graph.xml`).
-    let data = fs::read("fixture/tensor.bgr").unwrap();
-    println!("Read input tensor, size in bytes: {}", data.len());
-    context.set_input(0, wasi_nn::TensorType::F32, &[1, 3, 224, 224], &data)?;
-
-    // Execute the inference.
-    context.compute()?;
-    println!("Executed graph inference");
-
-    // Retrieve the output.
-    let mut output_buffer = vec![0f32; 1001];
-    context.get_output(0, &mut output_buffer[..])?;
-    println!(
-        "Found results, sorted top 5: {:?}",
-        &sort_results(&output_buffer)[..5]
-    );
-
+        .build_from_bytes([&xml, &weights])?;
+    let tensor = fs::read("fixture/tensor.bgr")
+        .expect("the tensor file to be mapped to the fixture directory");
+    let results = classify(graph, tensor)?;
+    let top_five = &sort_results(&results)[..5];
+    println!("found results, sorted top 5: {:?}", top_five);
     Ok(())
 }
-
-// Sort the buffer of probabilities. The graph places the match probability for
-// each class at the index for that class (e.g. the probability of class 42 is
-// placed at buffer[42]). Here we convert to a wrapping InferenceResult and sort
-// the results. It is unclear why the MobileNet output indices are "off by one"
-// but the `.skip(1)` below seems necessary to get results that make sense (e.g.
-// 763 = "revolver" vs 762 = "restaurant").
-fn sort_results(buffer: &[f32]) -> Vec<InferenceResult> {
-    let mut results: Vec<InferenceResult> = buffer
-        .iter()
-        .skip(1)
-        .enumerate()
-        .map(|(c, p)| InferenceResult(c, *p))
-        .collect();
-    results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
-    results
-}
-
-// A wrapper for class ID and match probabilities.
-#[derive(Debug, PartialEq)]
-struct InferenceResult(usize, f32);
diff --git a/crates/test-programs/src/bin/nn_image_classification_named.rs b/crates/test-programs/src/bin/nn_image_classification_named.rs
index 1226b172c..cac723d08 100644
--- a/crates/test-programs/src/bin/nn_image_classification_named.rs
+++ b/crates/test-programs/src/bin/nn_image_classification_named.rs
@@ -1,54 +1,15 @@
 use anyhow::Result;
 use std::fs;
-use wasi_nn::*;
+use test_programs::nn::{classify, sort_results};
+use wasi_nn::{ExecutionTarget, GraphBuilder, GraphEncoding};
 
 pub fn main() -> Result<()> {
-    // Load model from preloaded directory named "fixtures" which contains a model.[bin|xml] mobilenet model.
     let graph = GraphBuilder::new(GraphEncoding::Openvino, ExecutionTarget::CPU)
         .build_from_cache("fixtures")?;
-    println!("Loaded a graph: {:?}", graph);
-
-    let mut context = graph.init_execution_context()?;
-    println!("Created an execution context: {:?}", context);
-
-    // Load a tensor that precisely matches the graph input tensor (see
-    // `fixture/frozen_inference_graph.xml`).
-    let tensor_data = fs::read("fixture/tensor.bgr")?;
-    println!("Read input tensor, size in bytes: {}", tensor_data.len());
-    context.set_input(0, TensorType::F32, &[1, 3, 224, 224], &tensor_data)?;
-
-    // Execute the inference.
-    context.compute()?;
-    println!("Executed graph inference");
-
-    // Retrieve the output.
-    let mut output_buffer = vec![0f32; 1001];
-    context.get_output(0, &mut output_buffer[..])?;
-
-    println!(
-        "Found results, sorted top 5: {:?}",
-        &sort_results(&output_buffer)[..5]
-    );
+    let tensor = fs::read("fixture/tensor.bgr")
+        .expect("the tensor file to be mapped to the fixture directory");
+    let results = classify(graph, tensor)?;
+    let top_five = &sort_results(&results)[..5];
+    println!("found results, sorted top 5: {:?}", top_five);
     Ok(())
 }
-
-// Sort the buffer of probabilities. The graph places the match probability for
-// each class at the index for that class (e.g. the probability of class 42 is
-// placed at buffer[42]). Here we convert to a wrapping InferenceResult and sort
-// the results. It is unclear why the MobileNet output indices are "off by one"
-// but the `.skip(1)` below seems necessary to get results that make sense (e.g.
-// 763 = "revolver" vs 762 = "restaurant").
-fn sort_results(buffer: &[f32]) -> Vec<InferenceResult> {
-    let mut results: Vec<InferenceResult> = buffer
-        .iter()
-        .skip(1)
-        .enumerate()
-        .map(|(c, p)| InferenceResult(c, *p))
-        .collect();
-    results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
-    results
-}
-
-// A wrapper for class ID and match probabilities.
-#[derive(Debug, PartialEq)]
-struct InferenceResult(usize, f32);
diff --git a/crates/test-programs/src/bin/nn_image_classification_onnx.rs b/crates/test-programs/src/bin/nn_image_classification_onnx.rs
index 2e9b8c9e8..30d20330f 100644
--- a/crates/test-programs/src/bin/nn_image_classification_onnx.rs
+++ b/crates/test-programs/src/bin/nn_image_classification_onnx.rs
@@ -1,55 +1,17 @@
 use anyhow::Result;
 use std::fs;
-use wasi_nn::*;
+use test_programs::nn::{classify, sort_results};
+use wasi_nn::{ExecutionTarget, GraphBuilder, GraphEncoding};
 
 pub fn main() -> Result<()> {
-    let model = fs::read("fixture/model.onnx").unwrap();
-    println!("[ONNX] Read model, size in bytes: {}", model.len());
-
+    let model = fs::read("fixture/model.onnx")
+        .expect("the model file to be mapped to the fixture directory");
     let graph =
         GraphBuilder::new(GraphEncoding::Onnx, ExecutionTarget::CPU).build_from_bytes([&model])?;
-
-    let mut context = graph.init_execution_context()?;
-    println!(
-        "[ONNX] Created wasi-nn execution context with ID: {}",
-        context
-    );
-
-    // Prepare WASI-NN tensor - Tensor data is always a bytes vector
-    // Load a tensor that precisely matches the graph input tensor
-    let data = fs::read("fixture/tensor.bgr").unwrap();
-    println!("[ONNX] Read input tensor, size in bytes: {}", data.len());
-    context.set_input(0, wasi_nn::TensorType::F32, &[1, 3, 224, 224], &data)?;
-
-    // Execute the inferencing
-    context.compute()?;
-    println!("[ONNX] Executed graph inference");
-
-    // Retrieve the output.
-    let mut output_buffer = vec![0f32; 1000];
-    context.get_output(0, &mut output_buffer[..])?;
-    println!(
-        "[ONNX] Found results, sorted top 5: {:?}",
-        &sort_results(&output_buffer)[..5]
-    );
-
+    let tensor = fs::read("fixture/tensor.bgr")
+        .expect("the tensor file to be mapped to the fixture directory");
+    let results = classify(graph, tensor)?;
+    let top_five = &sort_results(&results)[..5];
+    println!("found results, sorted top 5: {:?}", top_five);
     Ok(())
 }
-
-// Sort the buffer of probabilities. The graph places the match probability for
-// each class at the index for that class (e.g. the probability of class 42 is
-// placed at buffer[42]). Here we convert to a wrapping InferenceResult and sort
-// the results.
-fn sort_results(buffer: &[f32]) -> Vec<InferenceResult> {
-    let mut results: Vec<InferenceResult> = buffer
-        .iter()
-        .enumerate()
-        .map(|(c, p)| InferenceResult(c, *p))
-        .collect();
-    results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
-    results
-}
-
-// A wrapper for class ID and match probabilities.
-#[derive(Debug, PartialEq)]
-struct InferenceResult(usize, f32);
diff --git a/crates/test-programs/src/bin/nn_image_classification_winml.rs b/crates/test-programs/src/bin/nn_image_classification_winml.rs
index 6996ff258..179d15ded 100644
--- a/crates/test-programs/src/bin/nn_image_classification_winml.rs
+++ b/crates/test-programs/src/bin/nn_image_classification_winml.rs
@@ -1,58 +1,17 @@
 use anyhow::Result;
 use std::fs;
-use std::time::Instant;
-use wasi_nn::*;
+use test_programs::nn::{classify, sort_results};
 
-pub fn main() -> Result<()> {
-    // Graph is supposed to be preloaded by `nn-graph` argument. The path ends with "mobilenet".
-    let graph =
-        wasi_nn::GraphBuilder::new(wasi_nn::GraphEncoding::Onnx, wasi_nn::ExecutionTarget::CPU)
-            .build_from_cache("mobilenet")
-            .unwrap();
-
-    let mut context = graph.init_execution_context().unwrap();
-    println!("Created an execution context.");
-
-    // Convert image to tensor data.
-    let tensor_data = fs::read("fixture/kitten.rgb")?;
-    context
-        .set_input(0, TensorType::F32, &[1, 3, 224, 224], &tensor_data)
-        .unwrap();
-
-    // Execute the inference.
-    let before_compute = Instant::now();
-    context.compute().unwrap();
-    println!(
-        "Executed graph inference, took {} ms.",
-        before_compute.elapsed().as_millis()
-    );
-
-    // Retrieve the output.
-    let mut output_buffer = vec![0f32; 1000];
-    context.get_output(0, &mut output_buffer[..]).unwrap();
+use wasi_nn::{ExecutionTarget, GraphBuilder, GraphEncoding};
 
-    let result = sort_results(&output_buffer);
-    println!("Found results, sorted top 5: {:?}", &result[..5]);
-    assert_eq!(result[0].0, 284);
+pub fn main() -> Result<()> {
+    let graph = GraphBuilder::new(GraphEncoding::Onnx, ExecutionTarget::CPU)
+        .build_from_cache("mobilenet")?;
+    let tensor = fs::read("fixture/kitten.rgb")
+        .expect("the tensor file to be mapped to the fixture directory");
+    let results = classify(graph, tensor)?;
+    let top_five = &sort_results(&results)[..5];
+    println!("found results, sorted top 5: {:?}", top_five);
+    assert_eq!(top_five[0].class_id(), 284);
     Ok(())
 }
-
-// Sort the buffer of probabilities. The graph places the match probability for each class at the
-// index for that class (e.g. the probability of class 42 is placed at buffer[42]). Here we convert
-// to a wrapping InferenceResult and sort the results. It is unclear why the MobileNet output
-// indices are "off by one" but the `.skip(1)` below seems necessary to get results that make sense
-// (e.g. 763 = "revolver" vs 762 = "restaurant")
-fn sort_results(buffer: &[f32]) -> Vec<InferenceResult> {
-    let mut results: Vec<InferenceResult> = buffer
-        .iter()
-        .skip(1)
-        .enumerate()
-        .map(|(c, p)| InferenceResult(c, *p))
-        .collect();
-    results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
-    results
-}
-
-// A wrapper for class ID and match probabilities.
-#[derive(Debug, PartialEq)]
-struct InferenceResult(usize, f32);
diff --git a/crates/test-programs/src/lib.rs b/crates/test-programs/src/lib.rs
index 9697cb631..dfa4541a8 100644
--- a/crates/test-programs/src/lib.rs
+++ b/crates/test-programs/src/lib.rs
@@ -1,4 +1,5 @@
 pub mod http;
+pub mod nn;
 pub mod preview1;
 pub mod sockets;
 
diff --git a/crates/test-programs/src/nn.rs b/crates/test-programs/src/nn.rs
new file mode 100644
index 000000000..cf25e4b89
--- /dev/null
+++ b/crates/test-programs/src/nn.rs
@@ -0,0 +1,68 @@
+use anyhow::Result;
+use std::time::Instant;
+use wasi_nn::{Graph, TensorType};
+
+/// Run a wasi-nn inference using a simple classifier model (single input,
+/// single output).
+pub fn classify(graph: Graph, tensor: Vec<u8>) -> Result<Vec<f32>> {
+    let mut context = graph.init_execution_context()?;
+    println!(
+        "[nn] created wasi-nn execution context with ID: {}",
+        context
+    );
+
+    // Many classifiers have a single input; currently, this test suite also
+    // uses tensors of the same shape, though this is not usually the case.
+    context.set_input(0, TensorType::F32, &[1, 3, 224, 224], &tensor)?;
+    println!("[nn] set input tensor: {} bytes", tensor.len());
+
+    let before = Instant::now();
+    context.compute()?;
+    println!(
+        "[nn] executed graph inference in {} ms",
+        before.elapsed().as_millis()
+    );
+
+    // Many classifiers emit probabilities as floating point values; here we
+    // convert the raw bytes to `f32` knowing all models used here use that
+    // type.
+    let mut output_buffer = vec![0u8; 1001 * std::mem::size_of::<f32>()];
+    let num_bytes = context.get_output(0, &mut output_buffer)?;
+    println!("[nn] retrieved output tensor: {} bytes", num_bytes);
+    let output: Vec<f32> = output_buffer[..num_bytes]
+        .chunks(4)
+        .map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]]))
+        .collect();
+    Ok(output)
+}
+
+/// Sort some classification probabilities.
+///
+/// Many classification models output a buffer of probabilities for each class,
+/// placing the match probability for each class at the index for that class
+/// (the probability of class `N` is stored at `probabilities[N]`).
+pub fn sort_results(probabilities: &[f32]) -> Vec<InferenceResult> {
+    // It is unclear why the MobileNet output indices are "off by one" but the
+    // `.skip(1)` below seems necessary to get results that make sense (e.g. 763
+    // = "revolver" vs 762 = "restaurant").
+    let mut results: Vec<InferenceResult> = probabilities
+        .iter()
+        .skip(1)
+        .enumerate()
+        .map(|(c, p)| InferenceResult(c, *p))
+        .collect();
+    results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
+    results
+}
+
+// A wrapper for class ID and match probabilities.
+#[derive(Debug, PartialEq)]
+pub struct InferenceResult(usize, f32);
+impl InferenceResult {
+    pub fn class_id(&self) -> usize {
+        self.0
+    }
+    pub fn probability(&self) -> f32 {
+        self.1
+    }
+}
diff --git a/tests/disas/indirect-call-caching-exclude-0-index.wat b/tests/disas/indirect-call-caching-exclude-0-index.wat
new file mode 100644
index 000000000..6744cf7b8
--- /dev/null
+++ b/tests/disas/indirect-call-caching-exclude-0-index.wat
@@ -0,0 +1,107 @@
+;;! target = "x86_64"
+;;! flags = [ "-Wcache-call-indirects=y" ]
+
+;; This test checks that we do *not* get the indirect-call caching optimization
+;; when it must not be used: in this case, because there is a non-null element
+;; at index 0 in the table (`0` is the default value for vmctx struct initialization
+;; and so is our "not yet cached" sentinel).
+
+(module
+ (table 10 10 funcref)
+
+ (func $f1 (result i32) i32.const 1)
+ (func $f2 (result i32) i32.const 2)
+ (func $f3 (result i32) i32.const 3)
+
+ (func (export "call_it") (param i32) (result i32)
+  local.get 0
+  call_indirect (result i32))
+
+ (elem (i32.const 0) func $f1 $f2 $f3))
+;; function u0:0(i64 vmctx, i64) -> i32 fast {
+;;     gv0 = vmctx
+;;     gv1 = load.i64 notrap aligned readonly gv0+8
+;;     gv2 = load.i64 notrap aligned gv1
+;;     stack_limit = gv2
+;;
+;;                                 block0(v0: i64, v1: i64):
+;; @003f                               v3 = iconst.i32 1
+;; @0041                               jump block1(v3)  ; v3 = 1
+;;
+;;                                 block1(v2: i32):
+;; @0041                               return v2
+;; }
+;;
+;; function u0:1(i64 vmctx, i64) -> i32 fast {
+;;     gv0 = vmctx
+;;     gv1 = load.i64 notrap aligned readonly gv0+8
+;;     gv2 = load.i64 notrap aligned gv1
+;;     stack_limit = gv2
+;;
+;;                                 block0(v0: i64, v1: i64):
+;; @0044                               v3 = iconst.i32 2
+;; @0046                               jump block1(v3)  ; v3 = 2
+;;
+;;                                 block1(v2: i32):
+;; @0046                               return v2
+;; }
+;;
+;; function u0:2(i64 vmctx, i64) -> i32 fast {
+;;     gv0 = vmctx
+;;     gv1 = load.i64 notrap aligned readonly gv0+8
+;;     gv2 = load.i64 notrap aligned gv1
+;;     stack_limit = gv2
+;;
+;;                                 block0(v0: i64, v1: i64):
+;; @0049                               v3 = iconst.i32 3
+;; @004b                               jump block1(v3)  ; v3 = 3
+;;
+;;                                 block1(v2: i32):
+;; @004b                               return v2
+;; }
+;;
+;; function u0:3(i64 vmctx, i64, i32) -> i32 fast {
+;;     gv0 = vmctx
+;;     gv1 = load.i64 notrap aligned readonly gv0+8
+;;     gv2 = load.i64 notrap aligned gv1
+;;     gv3 = vmctx
+;;     gv4 = load.i64 notrap aligned readonly gv3+88
+;;     sig0 = (i64 vmctx, i64) -> i32 fast
+;;     sig1 = (i64 vmctx, i32 uext, i32 uext) -> i64 system_v
+;;     fn0 = colocated u1:9 sig1
+;;     stack_limit = gv2
+;;
+;;                                 block0(v0: i64, v1: i64, v2: i32):
+;; @0050                               v4 = iconst.i32 10
+;; @0050                               v5 = icmp uge v2, v4  ; v4 = 10
+;; @0050                               v6 = uextend.i64 v2
+;; @0050                               v7 = global_value.i64 gv4
+;; @0050                               v8 = ishl_imm v6, 3
+;; @0050                               v9 = iadd v7, v8
+;; @0050                               v10 = iconst.i64 0
+;; @0050                               v11 = select_spectre_guard v5, v10, v9  ; v10 = 0
+;; @0050                               v12 = load.i64 table_oob aligned table v11
+;; @0050                               v13 = band_imm v12, -2
+;; @0050                               brif v12, block3(v13), block2
+;;
+;;                                 block2 cold:
+;; @0050                               v15 = iconst.i32 0
+;; @0050                               v16 = global_value.i64 gv3
+;; @0050                               v17 = call fn0(v16, v15, v2)  ; v15 = 0
+;; @0050                               jump block3(v17)
+;;
+;;                                 block3(v14: i64):
+;; @0050                               v18 = global_value.i64 gv3
+;; @0050                               v19 = load.i64 notrap aligned readonly v18+80
+;; @0050                               v20 = load.i32 notrap aligned readonly v19
+;; @0050                               v21 = load.i32 icall_null aligned readonly v14+24
+;; @0050                               v22 = icmp eq v21, v20
+;; @0050                               trapz v22, bad_sig
+;; @0050                               v23 = load.i64 notrap aligned readonly v14+16
+;; @0050                               v24 = load.i64 notrap aligned readonly v14+32
+;; @0050                               v25 = call_indirect sig0, v23(v24, v0)
+;; @0053                               jump block1(v25)
+;;
+;;                                 block1(v3: i32):
+;; @0053                               return v3
+;; }
diff --git a/tests/disas/indirect-call-caching-exclude-table-export.wat b/tests/disas/indirect-call-caching-exclude-table-export.wat
new file mode 100644
index 000000000..30ca6f0d5
--- /dev/null
+++ b/tests/disas/indirect-call-caching-exclude-table-export.wat
@@ -0,0 +1,106 @@
+;;! target = "x86_64"
+;;! flags = [ "-Wcache-call-indirects=y" ]
+
+;; This test checks that we do *not* get the indirect-call caching optimization
+;; when it must not be used: in this case, because the table is exported so
+;; could be mutated (invalidating the cache, which we would not detect).
+
+(module
+ (table (export "t") 10 10 funcref)
+
+ (func $f1 (result i32) i32.const 1)
+ (func $f2 (result i32) i32.const 2)
+ (func $f3 (result i32) i32.const 3)
+
+ (func (export "call_it") (param i32) (result i32)
+  local.get 0
+  call_indirect (result i32))
+
+ (elem (i32.const 1) func $f1 $f2 $f3))
+;; function u0:0(i64 vmctx, i64) -> i32 fast {
+;;     gv0 = vmctx
+;;     gv1 = load.i64 notrap aligned readonly gv0+8
+;;     gv2 = load.i64 notrap aligned gv1
+;;     stack_limit = gv2
+;;
+;;                                 block0(v0: i64, v1: i64):
+;; @0043                               v3 = iconst.i32 1
+;; @0045                               jump block1(v3)  ; v3 = 1
+;;
+;;                                 block1(v2: i32):
+;; @0045                               return v2
+;; }
+;;
+;; function u0:1(i64 vmctx, i64) -> i32 fast {
+;;     gv0 = vmctx
+;;     gv1 = load.i64 notrap aligned readonly gv0+8
+;;     gv2 = load.i64 notrap aligned gv1
+;;     stack_limit = gv2
+;;
+;;                                 block0(v0: i64, v1: i64):
+;; @0048                               v3 = iconst.i32 2
+;; @004a                               jump block1(v3)  ; v3 = 2
+;;
+;;                                 block1(v2: i32):
+;; @004a                               return v2
+;; }
+;;
+;; function u0:2(i64 vmctx, i64) -> i32 fast {
+;;     gv0 = vmctx
+;;     gv1 = load.i64 notrap aligned readonly gv0+8
+;;     gv2 = load.i64 notrap aligned gv1
+;;     stack_limit = gv2
+;;
+;;                                 block0(v0: i64, v1: i64):
+;; @004d                               v3 = iconst.i32 3
+;; @004f                               jump block1(v3)  ; v3 = 3
+;;
+;;                                 block1(v2: i32):
+;; @004f                               return v2
+;; }
+;;
+;; function u0:3(i64 vmctx, i64, i32) -> i32 fast {
+;;     gv0 = vmctx
+;;     gv1 = load.i64 notrap aligned readonly gv0+8
+;;     gv2 = load.i64 notrap aligned gv1
+;;     gv3 = vmctx
+;;     gv4 = load.i64 notrap aligned readonly gv3+88
+;;     sig0 = (i64 vmctx, i64) -> i32 fast
+;;     sig1 = (i64 vmctx, i32 uext, i32 uext) -> i64 system_v
+;;     fn0 = colocated u1:9 sig1
+;;     stack_limit = gv2
+;;
+;;                                 block0(v0: i64, v1: i64, v2: i32):
+;; @0054                               v4 = iconst.i32 10
+;; @0054                               v5 = icmp uge v2, v4  ; v4 = 10
+;; @0054                               v6 = uextend.i64 v2
+;; @0054                               v7 = global_value.i64 gv4
+;; @0054                               v8 = ishl_imm v6, 3
+;; @0054                               v9 = iadd v7, v8
+;; @0054                               v10 = iconst.i64 0
+;; @0054                               v11 = select_spectre_guard v5, v10, v9  ; v10 = 0
+;; @0054                               v12 = load.i64 table_oob aligned table v11
+;; @0054                               v13 = band_imm v12, -2
+;; @0054                               brif v12, block3(v13), block2
+;;
+;;                                 block2 cold:
+;; @0054                               v15 = iconst.i32 0
+;; @0054                               v16 = global_value.i64 gv3
+;; @0054                               v17 = call fn0(v16, v15, v2)  ; v15 = 0
+;; @0054                               jump block3(v17)
+;;
+;;                                 block3(v14: i64):
+;; @0054                               v18 = global_value.i64 gv3
+;; @0054                               v19 = load.i64 notrap aligned readonly v18+80
+;; @0054                               v20 = load.i32 notrap aligned readonly v19
+;; @0054                               v21 = load.i32 icall_null aligned readonly v14+24
+;; @0054                               v22 = icmp eq v21, v20
+;; @0054                               trapz v22, bad_sig
+;; @0054                               v23 = load.i64 notrap aligned readonly v14+16
+;; @0054                               v24 = load.i64 notrap aligned readonly v14+32
+;; @0054                               v25 = call_indirect sig0, v23(v24, v0)
+;; @0057                               jump block1(v25)
+;;
+;;                                 block1(v3: i32):
+;; @0057                               return v3
+;; }
diff --git a/tests/disas/indirect-call-caching-exclude-table-writes.wat b/tests/disas/indirect-call-caching-exclude-table-writes.wat
new file mode 100644
index 000000000..d167a16ee
--- /dev/null
+++ b/tests/disas/indirect-call-caching-exclude-table-writes.wat
@@ -0,0 +1,138 @@
+;;! target = "x86_64"
+;;! flags = [ "-Wcache-call-indirects=y" ]
+
+;; This test checks that we do *not* get the indirect-call caching optimization
+;; when it must not be used: in this case, because the table is updated with a
+;; `table.set` instruction (invalidating the cache, which we would not detect).
+
+(module
+ (table 10 10 funcref)
+
+ (func $f1 (result i32) i32.const 1)
+ (func $f2 (result i32) i32.const 2)
+ (func $f3 (result i32) i32.const 3)
+
+ (func (export "call_it") (param i32) (result i32)
+  local.get 0
+  call_indirect (result i32))
+
+ (func (export "update_table")
+  i32.const 1
+  ref.null func
+  table.set)
+
+ (elem (i32.const 1) func $f1 $f2 $f3))
+;; function u0:0(i64 vmctx, i64) -> i32 fast {
+;;     gv0 = vmctx
+;;     gv1 = load.i64 notrap aligned readonly gv0+8
+;;     gv2 = load.i64 notrap aligned gv1
+;;     stack_limit = gv2
+;;
+;;                                 block0(v0: i64, v1: i64):
+;; @0052                               v3 = iconst.i32 1
+;; @0054                               jump block1(v3)  ; v3 = 1
+;;
+;;                                 block1(v2: i32):
+;; @0054                               return v2
+;; }
+;;
+;; function u0:1(i64 vmctx, i64) -> i32 fast {
+;;     gv0 = vmctx
+;;     gv1 = load.i64 notrap aligned readonly gv0+8
+;;     gv2 = load.i64 notrap aligned gv1
+;;     stack_limit = gv2
+;;
+;;                                 block0(v0: i64, v1: i64):
+;; @0057                               v3 = iconst.i32 2
+;; @0059                               jump block1(v3)  ; v3 = 2
+;;
+;;                                 block1(v2: i32):
+;; @0059                               return v2
+;; }
+;;
+;; function u0:2(i64 vmctx, i64) -> i32 fast {
+;;     gv0 = vmctx
+;;     gv1 = load.i64 notrap aligned readonly gv0+8
+;;     gv2 = load.i64 notrap aligned gv1
+;;     stack_limit = gv2
+;;
+;;                                 block0(v0: i64, v1: i64):
+;; @005c                               v3 = iconst.i32 3
+;; @005e                               jump block1(v3)  ; v3 = 3
+;;
+;;                                 block1(v2: i32):
+;; @005e                               return v2
+;; }
+;;
+;; function u0:3(i64 vmctx, i64, i32) -> i32 fast {
+;;     gv0 = vmctx
+;;     gv1 = load.i64 notrap aligned readonly gv0+8
+;;     gv2 = load.i64 notrap aligned gv1
+;;     gv3 = vmctx
+;;     gv4 = load.i64 notrap aligned readonly gv3+88
+;;     sig0 = (i64 vmctx, i64) -> i32 fast
+;;     sig1 = (i64 vmctx, i32 uext, i32 uext) -> i64 system_v
+;;     fn0 = colocated u1:9 sig1
+;;     stack_limit = gv2
+;;
+;;                                 block0(v0: i64, v1: i64, v2: i32):
+;; @0063                               v4 = iconst.i32 10
+;; @0063                               v5 = icmp uge v2, v4  ; v4 = 10
+;; @0063                               v6 = uextend.i64 v2
+;; @0063                               v7 = global_value.i64 gv4
+;; @0063                               v8 = ishl_imm v6, 3
+;; @0063                               v9 = iadd v7, v8
+;; @0063                               v10 = iconst.i64 0
+;; @0063                               v11 = select_spectre_guard v5, v10, v9  ; v10 = 0
+;; @0063                               v12 = load.i64 table_oob aligned table v11
+;; @0063                               v13 = band_imm v12, -2
+;; @0063                               brif v12, block3(v13), block2
+;;
+;;                                 block2 cold:
+;; @0063                               v15 = iconst.i32 0
+;; @0063                               v16 = global_value.i64 gv3
+;; @0063                               v17 = call fn0(v16, v15, v2)  ; v15 = 0
+;; @0063                               jump block3(v17)
+;;
+;;                                 block3(v14: i64):
+;; @0063                               v18 = global_value.i64 gv3
+;; @0063                               v19 = load.i64 notrap aligned readonly v18+80
+;; @0063                               v20 = load.i32 notrap aligned readonly v19
+;; @0063                               v21 = load.i32 icall_null aligned readonly v14+24
+;; @0063                               v22 = icmp eq v21, v20
+;; @0063                               trapz v22, bad_sig
+;; @0063                               v23 = load.i64 notrap aligned readonly v14+16
+;; @0063                               v24 = load.i64 notrap aligned readonly v14+32
+;; @0063                               v25 = call_indirect sig0, v23(v24, v0)
+;; @0066                               jump block1(v25)
+;;
+;;                                 block1(v3: i32):
+;; @0066                               return v3
+;; }
+;;
+;; function u0:4(i64 vmctx, i64) fast {
+;;     gv0 = vmctx
+;;     gv1 = load.i64 notrap aligned readonly gv0+8
+;;     gv2 = load.i64 notrap aligned gv1
+;;     gv3 = vmctx
+;;     gv4 = load.i64 notrap aligned readonly gv3+88
+;;     stack_limit = gv2
+;;
+;;                                 block0(v0: i64, v1: i64):
+;; @0069                               v2 = iconst.i32 1
+;; @006b                               v3 = iconst.i64 0
+;; @006d                               v4 = iconst.i32 10
+;; @006d                               v5 = icmp uge v2, v4  ; v2 = 1, v4 = 10
+;; @006d                               v6 = uextend.i64 v2  ; v2 = 1
+;; @006d                               v7 = global_value.i64 gv4
+;; @006d                               v8 = ishl_imm v6, 3
+;; @006d                               v9 = iadd v7, v8
+;; @006d                               v10 = iconst.i64 0
+;; @006d                               v11 = select_spectre_guard v5, v10, v9  ; v10 = 0
+;; @006d                               v12 = bor_imm v3, 1  ; v3 = 0
+;; @006d                               store table_oob aligned table v12, v11
+;; @006f                               jump block1
+;;
+;;                                 block1:
+;; @006f                               return
+;; }
diff --git a/tests/disas/indirect-call-caching-slot-limit-1.wat b/tests/disas/indirect-call-caching-slot-limit-1.wat
new file mode 100644
index 000000000..937c0f9fe
--- /dev/null
+++ b/tests/disas/indirect-call-caching-slot-limit-1.wat
@@ -0,0 +1,157 @@
+;;! target = "x86_64"
+;;! flags = [ "-Wcache-call-indirects=y", "-Wmax-call-indirect-cache-slots=2" ]
+
+;; This test checks that we properly bound the number of call-indirect
+;; cache slots. The first case (here) is when the limit falls in the
+;; middle of a function. We set the limit to 2 above; we have 3
+;; `call_indirect`s below; the last should not have caching code.
+;;
+;; In particular, below we see the cache probe sequence in block0
+;; (first) and block3 (second); but the third call, starting in
+;; block8, has no cache slot access and just performs the checks
+;; unconditionally.
+
+(module
+ (table 10 10 funcref)
+
+ (func (export "call_it") (param i32) (result i32)
+  local.get 0
+  call_indirect (result i32)
+  call_indirect (result i32)
+  call_indirect (result i32)))
+  
+;; function u0:0(i64 vmctx, i64, i32) -> i32 fast {
+;;     gv0 = vmctx
+;;     gv1 = load.i64 notrap aligned readonly gv0+8
+;;     gv2 = load.i64 notrap aligned gv1
+;;     gv3 = vmctx
+;;     gv4 = load.i64 notrap aligned readonly gv3+88
+;;     sig0 = (i64 vmctx, i64) -> i32 fast
+;;     sig1 = (i64 vmctx, i32 uext, i32 uext) -> i64 system_v
+;;     fn0 = colocated u1:9 sig1
+;;     stack_limit = gv2
+;;
+;;                                 block0(v0: i64, v1: i64, v2: i32):
+;; @0033                               v4 = global_value.i64 gv3
+;; @0033                               v5 = iadd_imm v4, 152
+;; @0033                               v6 = load.i32 notrap aligned v5+8
+;; @0033                               v7 = load.i64 notrap aligned v5
+;; @0033                               v8 = icmp eq v6, v2
+;; @0033                               brif v8, block3(v7, v4), block2
+;;
+;;                                 block2 cold:
+;; @0033                               v9 = iconst.i32 10
+;; @0033                               v10 = icmp.i32 uge v2, v9  ; v9 = 10
+;; @0033                               v11 = uextend.i64 v2
+;; @0033                               v12 = global_value.i64 gv4
+;; @0033                               v13 = ishl_imm v11, 3
+;; @0033                               v14 = iadd v12, v13
+;; @0033                               v15 = iconst.i64 0
+;; @0033                               v16 = select_spectre_guard v10, v15, v14  ; v15 = 0
+;; @0033                               v17 = load.i64 table_oob aligned table v16
+;; @0033                               v18 = band_imm v17, -2
+;; @0033                               brif v17, block6(v18), block5
+;;
+;;                                 block4 cold:
+;; @0033                               store.i32 notrap aligned v2, v5+8
+;; @0033                               store.i64 notrap aligned v28, v5
+;; @0033                               jump block3(v28, v29)
+;;
+;;                                 block3(v31: i64, v32: i64):
+;; @0033                               v33 = call_indirect sig0, v31(v32, v0)
+;; @0036                               v34 = global_value.i64 gv3
+;; @0036                               v35 = iadd_imm v34, 168
+;; @0036                               v36 = load.i32 notrap aligned v35+8
+;; @0036                               v37 = load.i64 notrap aligned v35
+;; @0036                               v38 = icmp eq v36, v33
+;; @0036                               brif v38, block8(v37, v34), block7
+;;
+;;                                 block7 cold:
+;; @0036                               v39 = iconst.i32 10
+;; @0036                               v40 = icmp.i32 uge v33, v39  ; v39 = 10
+;; @0036                               v41 = uextend.i64 v33
+;; @0036                               v42 = global_value.i64 gv4
+;; @0036                               v43 = ishl_imm v41, 3
+;; @0036                               v44 = iadd v42, v43
+;; @0036                               v45 = iconst.i64 0
+;; @0036                               v46 = select_spectre_guard v40, v45, v44  ; v45 = 0
+;; @0036                               v47 = load.i64 table_oob aligned table v46
+;; @0036                               v48 = band_imm v47, -2
+;; @0036                               brif v47, block11(v48), block10
+;;
+;;                                 block9 cold:
+;; @0036                               store.i32 notrap aligned v33, v35+8
+;; @0036                               store.i64 notrap aligned v58, v35
+;; @0036                               jump block8(v58, v59)
+;;
+;;                                 block8(v61: i64, v62: i64):
+;; @0036                               v63 = call_indirect sig0, v61(v62, v0)
+;; @0039                               v64 = iconst.i32 10
+;; @0039                               v65 = icmp uge v63, v64  ; v64 = 10
+;; @0039                               v66 = uextend.i64 v63
+;; @0039                               v67 = global_value.i64 gv4
+;; @0039                               v68 = ishl_imm v66, 3
+;; @0039                               v69 = iadd v67, v68
+;; @0039                               v70 = iconst.i64 0
+;; @0039                               v71 = select_spectre_guard v65, v70, v69  ; v70 = 0
+;; @0039                               v72 = load.i64 table_oob aligned table v71
+;; @0039                               v73 = band_imm v72, -2
+;; @0039                               brif v72, block13(v73), block12
+;;
+;;                                 block5 cold:
+;; @0033                               v20 = iconst.i32 0
+;; @0033                               v21 = global_value.i64 gv3
+;; @0033                               v22 = call fn0(v21, v20, v2)  ; v20 = 0
+;; @0033                               jump block6(v22)
+;;
+;;                                 block6(v19: i64) cold:
+;; @0033                               v23 = global_value.i64 gv3
+;; @0033                               v24 = load.i64 notrap aligned readonly v23+80
+;; @0033                               v25 = load.i32 notrap aligned readonly v24+4
+;; @0033                               v26 = load.i32 icall_null aligned readonly v19+24
+;; @0033                               v27 = icmp eq v26, v25
+;; @0033                               trapz v27, bad_sig
+;; @0033                               v28 = load.i64 notrap aligned readonly v19+16
+;; @0033                               v29 = load.i64 notrap aligned readonly v19+32
+;; @0033                               v30 = icmp eq v29, v4
+;; @0033                               brif v30, block4, block3(v28, v29)
+;;
+;;                                 block10 cold:
+;; @0036                               v50 = iconst.i32 0
+;; @0036                               v51 = global_value.i64 gv3
+;; @0036                               v52 = call fn0(v51, v50, v33)  ; v50 = 0
+;; @0036                               jump block11(v52)
+;;
+;;                                 block11(v49: i64) cold:
+;; @0036                               v53 = global_value.i64 gv3
+;; @0036                               v54 = load.i64 notrap aligned readonly v53+80
+;; @0036                               v55 = load.i32 notrap aligned readonly v54+4
+;; @0036                               v56 = load.i32 icall_null aligned readonly v49+24
+;; @0036                               v57 = icmp eq v56, v55
+;; @0036                               trapz v57, bad_sig
+;; @0036                               v58 = load.i64 notrap aligned readonly v49+16
+;; @0036                               v59 = load.i64 notrap aligned readonly v49+32
+;; @0036                               v60 = icmp eq v59, v34
+;; @0036                               brif v60, block9, block8(v58, v59)
+;;
+;;                                 block12 cold:
+;; @0039                               v75 = iconst.i32 0
+;; @0039                               v76 = global_value.i64 gv3
+;; @0039                               v77 = call fn0(v76, v75, v63)  ; v75 = 0
+;; @0039                               jump block13(v77)
+;;
+;;                                 block13(v74: i64):
+;; @0039                               v78 = global_value.i64 gv3
+;; @0039                               v79 = load.i64 notrap aligned readonly v78+80
+;; @0039                               v80 = load.i32 notrap aligned readonly v79+4
+;; @0039                               v81 = load.i32 icall_null aligned readonly v74+24
+;; @0039                               v82 = icmp eq v81, v80
+;; @0039                               trapz v82, bad_sig
+;; @0039                               v83 = load.i64 notrap aligned readonly v74+16
+;; @0039                               v84 = load.i64 notrap aligned readonly v74+32
+;; @0039                               v85 = call_indirect sig0, v83(v84, v0)
+;; @003c                               jump block1(v85)
+;;
+;;                                 block1(v3: i32):
+;; @003c                               return v3
+;; }
diff --git a/tests/disas/indirect-call-caching-slot-limit-2.wat b/tests/disas/indirect-call-caching-slot-limit-2.wat
new file mode 100644
index 000000000..a8b89451c
--- /dev/null
+++ b/tests/disas/indirect-call-caching-slot-limit-2.wat
@@ -0,0 +1,180 @@
+;;! target = "x86_64"
+;;! flags = [ "-Wcache-call-indirects=y", "-Wmax-call-indirect-cache-slots=2" ]
+
+;; This test checks that we properly bound the number of call-indirect
+;; cache slots. The second case (here) is when the limit falls
+;; entirely before a function. We set the limit to 2 above; we have 2
+;; callsites in the first function; the second function should have no
+;; caching.
+;;
+;; In particular, below we see the cache probe sequence in block0
+;; (first) and block3 (second) in `u0:0` (`call_it`); but the call in
+;; the second function in `u0:1` (`call_it_2`), starting in block0 in
+;; that function, has no cache slot access and just performs the
+;; checks unconditionally.
+
+(module
+ (table 10 10 funcref)
+
+ (func (export "call_it") (param i32) (result i32)
+  local.get 0
+  call_indirect (result i32)
+  call_indirect (result i32))
+ 
+ (func (export "call_it_2") (param i32) (result i32)
+  local.get 0
+  call_indirect (result i32)))
+  
+;; function u0:0(i64 vmctx, i64, i32) -> i32 fast {
+;;     gv0 = vmctx
+;;     gv1 = load.i64 notrap aligned readonly gv0+8
+;;     gv2 = load.i64 notrap aligned gv1
+;;     gv3 = vmctx
+;;     gv4 = load.i64 notrap aligned readonly gv3+88
+;;     sig0 = (i64 vmctx, i64) -> i32 fast
+;;     sig1 = (i64 vmctx, i32 uext, i32 uext) -> i64 system_v
+;;     fn0 = colocated u1:9 sig1
+;;     stack_limit = gv2
+;;
+;;                                 block0(v0: i64, v1: i64, v2: i32):
+;; @0040                               v4 = global_value.i64 gv3
+;; @0040                               v5 = iadd_imm v4, 192
+;; @0040                               v6 = load.i32 notrap aligned v5+8
+;; @0040                               v7 = load.i64 notrap aligned v5
+;; @0040                               v8 = icmp eq v6, v2
+;; @0040                               brif v8, block3(v7, v4), block2
+;;
+;;                                 block2 cold:
+;; @0040                               v9 = iconst.i32 10
+;; @0040                               v10 = icmp.i32 uge v2, v9  ; v9 = 10
+;; @0040                               v11 = uextend.i64 v2
+;; @0040                               v12 = global_value.i64 gv4
+;; @0040                               v13 = ishl_imm v11, 3
+;; @0040                               v14 = iadd v12, v13
+;; @0040                               v15 = iconst.i64 0
+;; @0040                               v16 = select_spectre_guard v10, v15, v14  ; v15 = 0
+;; @0040                               v17 = load.i64 table_oob aligned table v16
+;; @0040                               v18 = band_imm v17, -2
+;; @0040                               brif v17, block6(v18), block5
+;;
+;;                                 block4 cold:
+;; @0040                               store.i32 notrap aligned v2, v5+8
+;; @0040                               store.i64 notrap aligned v28, v5
+;; @0040                               jump block3(v28, v29)
+;;
+;;                                 block3(v31: i64, v32: i64):
+;; @0040                               v33 = call_indirect sig0, v31(v32, v0)
+;; @0043                               v34 = global_value.i64 gv3
+;; @0043                               v35 = iadd_imm v34, 208
+;; @0043                               v36 = load.i32 notrap aligned v35+8
+;; @0043                               v37 = load.i64 notrap aligned v35
+;; @0043                               v38 = icmp eq v36, v33
+;; @0043                               brif v38, block8(v37, v34), block7
+;;
+;;                                 block7 cold:
+;; @0043                               v39 = iconst.i32 10
+;; @0043                               v40 = icmp.i32 uge v33, v39  ; v39 = 10
+;; @0043                               v41 = uextend.i64 v33
+;; @0043                               v42 = global_value.i64 gv4
+;; @0043                               v43 = ishl_imm v41, 3
+;; @0043                               v44 = iadd v42, v43
+;; @0043                               v45 = iconst.i64 0
+;; @0043                               v46 = select_spectre_guard v40, v45, v44  ; v45 = 0
+;; @0043                               v47 = load.i64 table_oob aligned table v46
+;; @0043                               v48 = band_imm v47, -2
+;; @0043                               brif v47, block11(v48), block10
+;;
+;;                                 block9 cold:
+;; @0043                               store.i32 notrap aligned v33, v35+8
+;; @0043                               store.i64 notrap aligned v58, v35
+;; @0043                               jump block8(v58, v59)
+;;
+;;                                 block8(v61: i64, v62: i64):
+;; @0043                               v63 = call_indirect sig0, v61(v62, v0)
+;; @0046                               jump block1(v63)
+;;
+;;                                 block5 cold:
+;; @0040                               v20 = iconst.i32 0
+;; @0040                               v21 = global_value.i64 gv3
+;; @0040                               v22 = call fn0(v21, v20, v2)  ; v20 = 0
+;; @0040                               jump block6(v22)
+;;
+;;                                 block6(v19: i64) cold:
+;; @0040                               v23 = global_value.i64 gv3
+;; @0040                               v24 = load.i64 notrap aligned readonly v23+80
+;; @0040                               v25 = load.i32 notrap aligned readonly v24+4
+;; @0040                               v26 = load.i32 icall_null aligned readonly v19+24
+;; @0040                               v27 = icmp eq v26, v25
+;; @0040                               trapz v27, bad_sig
+;; @0040                               v28 = load.i64 notrap aligned readonly v19+16
+;; @0040                               v29 = load.i64 notrap aligned readonly v19+32
+;; @0040                               v30 = icmp eq v29, v4
+;; @0040                               brif v30, block4, block3(v28, v29)
+;;
+;;                                 block10 cold:
+;; @0043                               v50 = iconst.i32 0
+;; @0043                               v51 = global_value.i64 gv3
+;; @0043                               v52 = call fn0(v51, v50, v33)  ; v50 = 0
+;; @0043                               jump block11(v52)
+;;
+;;                                 block11(v49: i64) cold:
+;; @0043                               v53 = global_value.i64 gv3
+;; @0043                               v54 = load.i64 notrap aligned readonly v53+80
+;; @0043                               v55 = load.i32 notrap aligned readonly v54+4
+;; @0043                               v56 = load.i32 icall_null aligned readonly v49+24
+;; @0043                               v57 = icmp eq v56, v55
+;; @0043                               trapz v57, bad_sig
+;; @0043                               v58 = load.i64 notrap aligned readonly v49+16
+;; @0043                               v59 = load.i64 notrap aligned readonly v49+32
+;; @0043                               v60 = icmp eq v59, v34
+;; @0043                               brif v60, block9, block8(v58, v59)
+;;
+;;                                 block1(v3: i32):
+;; @0046                               return v3
+;; }
+;;
+;; function u0:1(i64 vmctx, i64, i32) -> i32 fast {
+;;     gv0 = vmctx
+;;     gv1 = load.i64 notrap aligned readonly gv0+8
+;;     gv2 = load.i64 notrap aligned gv1
+;;     gv3 = vmctx
+;;     gv4 = load.i64 notrap aligned readonly gv3+88
+;;     sig0 = (i64 vmctx, i64) -> i32 fast
+;;     sig1 = (i64 vmctx, i32 uext, i32 uext) -> i64 system_v
+;;     fn0 = colocated u1:9 sig1
+;;     stack_limit = gv2
+;;
+;;                                 block0(v0: i64, v1: i64, v2: i32):
+;; @004b                               v4 = iconst.i32 10
+;; @004b                               v5 = icmp uge v2, v4  ; v4 = 10
+;; @004b                               v6 = uextend.i64 v2
+;; @004b                               v7 = global_value.i64 gv4
+;; @004b                               v8 = ishl_imm v6, 3
+;; @004b                               v9 = iadd v7, v8
+;; @004b                               v10 = iconst.i64 0
+;; @004b                               v11 = select_spectre_guard v5, v10, v9  ; v10 = 0
+;; @004b                               v12 = load.i64 table_oob aligned table v11
+;; @004b                               v13 = band_imm v12, -2
+;; @004b                               brif v12, block3(v13), block2
+;;
+;;                                 block2 cold:
+;; @004b                               v15 = iconst.i32 0
+;; @004b                               v16 = global_value.i64 gv3
+;; @004b                               v17 = call fn0(v16, v15, v2)  ; v15 = 0
+;; @004b                               jump block3(v17)
+;;
+;;                                 block3(v14: i64):
+;; @004b                               v18 = global_value.i64 gv3
+;; @004b                               v19 = load.i64 notrap aligned readonly v18+80
+;; @004b                               v20 = load.i32 notrap aligned readonly v19+4
+;; @004b                               v21 = load.i32 icall_null aligned readonly v14+24
+;; @004b                               v22 = icmp eq v21, v20
+;; @004b                               trapz v22, bad_sig
+;; @004b                               v23 = load.i64 notrap aligned readonly v14+16
+;; @004b                               v24 = load.i64 notrap aligned readonly v14+32
+;; @004b                               v25 = call_indirect sig0, v23(v24, v0)
+;; @004e                               jump block1(v25)
+;;
+;;                                 block1(v3: i32):
+;; @004e                               return v3
+;; }
diff --git a/tests/disas/indirect-call-caching.wat b/tests/disas/indirect-call-caching.wat
new file mode 100644
index 000000000..5ca21a148
--- /dev/null
+++ b/tests/disas/indirect-call-caching.wat
@@ -0,0 +1,125 @@
+;;! target = "x86_64"
+;;! flags = [ "-Wcache-call-indirects=y" ]
+
+;; This test checks that we get the indirect-call caching optimization
+;; where it should be applicable (immutable table, null 0-index).
+;;
+;; The key bit in the expectation below is the cached-index load (v6),
+;; compare (v7), branch, fastpath in block2/block4.
+
+(module
+ (table 10 10 funcref)
+
+ (func $f1 (result i32) i32.const 1)
+ (func $f2 (result i32) i32.const 2)
+ (func $f3 (result i32) i32.const 3)
+
+ (func (export "call_it") (param i32) (result i32)
+  local.get 0
+  call_indirect (result i32))
+
+ (elem (i32.const 1) func $f1 $f2 $f3))
+;; function u0:0(i64 vmctx, i64) -> i32 fast {
+;;     gv0 = vmctx
+;;     gv1 = load.i64 notrap aligned readonly gv0+8
+;;     gv2 = load.i64 notrap aligned gv1
+;;     stack_limit = gv2
+;;
+;;                                 block0(v0: i64, v1: i64):
+;; @003f                               v3 = iconst.i32 1
+;; @0041                               jump block1(v3)  ; v3 = 1
+;;
+;;                                 block1(v2: i32):
+;; @0041                               return v2
+;; }
+;;
+;; function u0:1(i64 vmctx, i64) -> i32 fast {
+;;     gv0 = vmctx
+;;     gv1 = load.i64 notrap aligned readonly gv0+8
+;;     gv2 = load.i64 notrap aligned gv1
+;;     stack_limit = gv2
+;;
+;;                                 block0(v0: i64, v1: i64):
+;; @0044                               v3 = iconst.i32 2
+;; @0046                               jump block1(v3)  ; v3 = 2
+;;
+;;                                 block1(v2: i32):
+;; @0046                               return v2
+;; }
+;;
+;; function u0:2(i64 vmctx, i64) -> i32 fast {
+;;     gv0 = vmctx
+;;     gv1 = load.i64 notrap aligned readonly gv0+8
+;;     gv2 = load.i64 notrap aligned gv1
+;;     stack_limit = gv2
+;;
+;;                                 block0(v0: i64, v1: i64):
+;; @0049                               v3 = iconst.i32 3
+;; @004b                               jump block1(v3)  ; v3 = 3
+;;
+;;                                 block1(v2: i32):
+;; @004b                               return v2
+;; }
+;;
+;; function u0:3(i64 vmctx, i64, i32) -> i32 fast {
+;;     gv0 = vmctx
+;;     gv1 = load.i64 notrap aligned readonly gv0+8
+;;     gv2 = load.i64 notrap aligned gv1
+;;     gv3 = vmctx
+;;     gv4 = load.i64 notrap aligned readonly gv3+88
+;;     sig0 = (i64 vmctx, i64) -> i32 fast
+;;     sig1 = (i64 vmctx, i32 uext, i32 uext) -> i64 system_v
+;;     fn0 = colocated u1:9 sig1
+;;     stack_limit = gv2
+;;
+;;                                 block0(v0: i64, v1: i64, v2: i32):
+;; @0050                               v4 = global_value.i64 gv3
+;; @0050                               v5 = iadd_imm v4, 272
+;; @0050                               v6 = load.i32 notrap aligned v5+8
+;; @0050                               v7 = load.i64 notrap aligned v5
+;; @0050                               v8 = icmp eq v6, v2
+;; @0050                               brif v8, block3(v7, v4), block2
+;;
+;;                                 block2 cold:
+;; @0050                               v9 = iconst.i32 10
+;; @0050                               v10 = icmp.i32 uge v2, v9  ; v9 = 10
+;; @0050                               v11 = uextend.i64 v2
+;; @0050                               v12 = global_value.i64 gv4
+;; @0050                               v13 = ishl_imm v11, 3
+;; @0050                               v14 = iadd v12, v13
+;; @0050                               v15 = iconst.i64 0
+;; @0050                               v16 = select_spectre_guard v10, v15, v14  ; v15 = 0
+;; @0050                               v17 = load.i64 table_oob aligned table v16
+;; @0050                               v18 = band_imm v17, -2
+;; @0050                               brif v17, block6(v18), block5
+;;
+;;                                 block4 cold:
+;; @0050                               store.i32 notrap aligned v2, v5+8
+;; @0050                               store.i64 notrap aligned v28, v5
+;; @0050                               jump block3(v28, v29)
+;;
+;;                                 block3(v31: i64, v32: i64):
+;; @0050                               v33 = call_indirect sig0, v31(v32, v0)
+;; @0053                               jump block1(v33)
+;;
+;;                                 block5 cold:
+;; @0050                               v20 = iconst.i32 0
+;; @0050                               v21 = global_value.i64 gv3
+;; @0050                               v22 = call fn0(v21, v20, v2)  ; v20 = 0
+;; @0050                               jump block6(v22)
+;;
+;;                                 block6(v19: i64) cold:
+;; @0050                               v23 = global_value.i64 gv3
+;; @0050                               v24 = load.i64 notrap aligned readonly v23+80
+;; @0050                               v25 = load.i32 notrap aligned readonly v24
+;; @0050                               v26 = load.i32 icall_null aligned readonly v19+24
+;; @0050                               v27 = icmp eq v26, v25
+;; @0050                               trapz v27, bad_sig
+;; @0050                               v28 = load.i64 notrap aligned readonly v19+16
+;; @0050                               v29 = load.i64 notrap aligned readonly v19+32
+;; @0050                               v30 = icmp eq v29, v4
+;; @0050                               brif v30, block4, block3(v28, v29)
+;;
+;;                                 block1(v3: i32):
+;; @0053                               return v3
+;; }
diff --git a/tests/disas/indirect-call-no-caching.wat b/tests/disas/indirect-call-no-caching.wat
new file mode 100644
index 000000000..ef93754c0
--- /dev/null
+++ b/tests/disas/indirect-call-no-caching.wat
@@ -0,0 +1,109 @@
+;;! target = "x86_64"
+
+;; This test checks that we do *not* get the indirect-call caching optimization
+;; when it is not enabled, because it is off by default.
+;;
+;; The key bit in the expectation below is that the call sequence in
+;; `u0:3` below goes straight to the bounds-check (v5), lazy-table
+;; init (masking of bits with v13), and loading of the funcref fields
+;; in block3, with no caching fastpath.
+
+(module
+ (table 10 10 funcref)
+
+ (func $f1 (result i32) i32.const 1)
+ (func $f2 (result i32) i32.const 2)
+ (func $f3 (result i32) i32.const 3)
+
+ (func (export "call_it") (param i32) (result i32)
+  local.get 0
+  call_indirect (result i32))
+
+ (elem (i32.const 1) func $f1 $f2 $f3))
+;; function u0:0(i64 vmctx, i64) -> i32 fast {
+;;     gv0 = vmctx
+;;     gv1 = load.i64 notrap aligned readonly gv0+8
+;;     gv2 = load.i64 notrap aligned gv1
+;;     stack_limit = gv2
+;;
+;;                                 block0(v0: i64, v1: i64):
+;; @003f                               v3 = iconst.i32 1
+;; @0041                               jump block1(v3)  ; v3 = 1
+;;
+;;                                 block1(v2: i32):
+;; @0041                               return v2
+;; }
+;;
+;; function u0:1(i64 vmctx, i64) -> i32 fast {
+;;     gv0 = vmctx
+;;     gv1 = load.i64 notrap aligned readonly gv0+8
+;;     gv2 = load.i64 notrap aligned gv1
+;;     stack_limit = gv2
+;;
+;;                                 block0(v0: i64, v1: i64):
+;; @0044                               v3 = iconst.i32 2
+;; @0046                               jump block1(v3)  ; v3 = 2
+;;
+;;                                 block1(v2: i32):
+;; @0046                               return v2
+;; }
+;;
+;; function u0:2(i64 vmctx, i64) -> i32 fast {
+;;     gv0 = vmctx
+;;     gv1 = load.i64 notrap aligned readonly gv0+8
+;;     gv2 = load.i64 notrap aligned gv1
+;;     stack_limit = gv2
+;;
+;;                                 block0(v0: i64, v1: i64):
+;; @0049                               v3 = iconst.i32 3
+;; @004b                               jump block1(v3)  ; v3 = 3
+;;
+;;                                 block1(v2: i32):
+;; @004b                               return v2
+;; }
+;;
+;; function u0:3(i64 vmctx, i64, i32) -> i32 fast {
+;;     gv0 = vmctx
+;;     gv1 = load.i64 notrap aligned readonly gv0+8
+;;     gv2 = load.i64 notrap aligned gv1
+;;     gv3 = vmctx
+;;     gv4 = load.i64 notrap aligned readonly gv3+88
+;;     sig0 = (i64 vmctx, i64) -> i32 fast
+;;     sig1 = (i64 vmctx, i32 uext, i32 uext) -> i64 system_v
+;;     fn0 = colocated u1:9 sig1
+;;     stack_limit = gv2
+;;
+;;                                 block0(v0: i64, v1: i64, v2: i32):
+;; @0050                               v4 = iconst.i32 10
+;; @0050                               v5 = icmp uge v2, v4  ; v4 = 10
+;; @0050                               v6 = uextend.i64 v2
+;; @0050                               v7 = global_value.i64 gv4
+;; @0050                               v8 = ishl_imm v6, 3
+;; @0050                               v9 = iadd v7, v8
+;; @0050                               v10 = iconst.i64 0
+;; @0050                               v11 = select_spectre_guard v5, v10, v9  ; v10 = 0
+;; @0050                               v12 = load.i64 table_oob aligned table v11
+;; @0050                               v13 = band_imm v12, -2
+;; @0050                               brif v12, block3(v13), block2
+;;
+;;                                 block2 cold:
+;; @0050                               v15 = iconst.i32 0
+;; @0050                               v16 = global_value.i64 gv3
+;; @0050                               v17 = call fn0(v16, v15, v2)  ; v15 = 0
+;; @0050                               jump block3(v17)
+;;
+;;                                 block3(v14: i64):
+;; @0050                               v18 = global_value.i64 gv3
+;; @0050                               v19 = load.i64 notrap aligned readonly v18+80
+;; @0050                               v20 = load.i32 notrap aligned readonly v19
+;; @0050                               v21 = load.i32 icall_null aligned readonly v14+24
+;; @0050                               v22 = icmp eq v21, v20
+;; @0050                               trapz v22, bad_sig
+;; @0050                               v23 = load.i64 notrap aligned readonly v14+16
+;; @0050                               v24 = load.i64 notrap aligned readonly v14+32
+;; @0050                               v25 = call_indirect sig0, v23(v24, v0)
+;; @0053                               jump block1(v25)
+;;
+;;                                 block1(v3: i32):
+;; @0053                               return v3
+;; }