Repository: bytecodealliance/wasmtime
Pull request #8509 (2024-04-29 22:44:42)

Wasmtime: add one-entry call-indirect caching.
In WebAssembly, an indirect call is somewhat slow, because of the indirection required by CFI (control-flow integrity) sandboxing. In particular, a "function pointer" in most source languages compiled to Wasm is represented by an index into a table of funcrefs. The `call_indirect` instruction then has to do the following steps to invoke a function pointer:

- Load the funcref table's base and length values from the vmctx.
- Bounds-check the invoked index against the actual table size; trap if out-of-bounds.
- Spectre mitigation (cmove) on that bounds-check.
- Load the `vmfuncref` from the table given base and index.
  - For lazy table init, check if this is a non-initialized funcref pointer, and initialize the entry.
- Load the signature from the funcref struct and compare it against the `call_indirect`'s expected signature; trap if wrong.
- Load the actual code pointer for the callee's Wasm-ABI entry point.
- Load the callee vmctx (which may be different for a cross-module call).
- Put that vmctx in arg 0, our vmctx in arg 1, and invoke the loaded code pointer with an indirect call instruction.

Compare and contrast to the process involved in invoking a native function pointer:

- Invoke the code pointer with an indirect call instruction.

This overhead buys us something -- it is part of the SFI sandbox boundary -- but it is very repetitive and unnecessary work in *most* cases when indirect function calls are performed repeatedly (such as within an inner loop).

This PR introduces the idea of *caching*: if we know that the result of all the above checks won't change, then if we use the same index as "the last time" (for some definition), we can skip straight to the "invoke the code pointer" step, with a cached code pointer from that last time.

Concretely, it introduces a two-word struct inlined into the vmctx for each `call_indirect` instruction in the module (up to a limit):

- The last invoked index;
- The code pointer that index corresponded to.

When compiling the module, we check whether the table could possibly be mutable at a given index once read: any instructions like `table.set`, or the whole table exported thus writable from the outside. We also check whether index 0 is a non-null funcref. If neither of these things are true, then we know we can cache an index-to-code-pointer mapping, and we know we can use index 0 as a sentinel for "no cached value".

We then make use of the struct for each indirect call site and generate code to check if the index matches; if so, call cached pointer; if not, load the vmfuncref, check the signature, check that the callee vmctx is the same as caller (intra-module case), and stash the code pointer and index away (fill the cache), then make the call.

On an in-development branch of SpiderMonkey-in-Wasm with ICs (using indirect calls), this is about a 20% speedup; I haven't yet measured on other benchmarks. It is expected that this might be an instantiation-time slowdown due to a larger vmctx (but we could use madvise to zero if needed).

This feature is off by default right now.

<!--
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
-->


cfallin: (I'll run some benchmarks to see what the compile-time impact is like, and how this benefits non-SpiderMonkey workloads; TBD.)
cfallin: Benchmarks:

- Sightglass measures the following for `spidermonkey.wasm`:
```
compilation :: cycles :: benchmarks/spidermonkey/benchmark.wasm

  Δ = 1456249767.40 ± 1315409020.02 (confidence = 99%)

  original.so is 1.01x to 1.17x faster than cache-indirect-calls.so!

  [17934023164 18070879867.60 18215783322] cache-indirect-calls.so
  [15377276916 16614630100.20 17840489432] original.so

execution :: cycles :: benchmarks/spidermonkey/benchmark.wasm

  Δ = 27069455.80 ± 22219096.46 (confidence = 99%)

  original.so is 1.00x to 1.05x faster than cache-indirect-calls.so!

  [1079208474 1095097604.00 1142114396] cache-indirect-calls.so
  [1035034082 1068028148.20 1094035960] original.so

instantiation :: cycles :: benchmarks/spidermonkey/benchmark.wasm

  No difference in performance.

  [834480 856949.40 902120] cache-indirect-calls.so
  [831744 860156.60 978994] original.so
```

so compilation time does take a little hit but my system is noisy; somewhere between 1% and 17%, the mean looks to be around 8.7%. Instantiation apparently unaffected. And execution time... slightly slower? (But again, noisy system.)

I tried running the next benchmark in the list (pulldown-cmark) and Sightglass crashed mysteriously, so on to trusty `hyperfine`: no deltas in runtime on spidermonkey or bz2, my two usual suspects.

I suspect that mostly we'll see mostly in-the-noise results for most benchmarks because indirect calls are somewhat rare in C-ish code; it will matter mostly when compiling dynamic languages, or something like Java with a lot of virtual calls. The speedup on weval'd SpiderMonkey is real though so I'd like to at least have this in tree as an option :-) 
github-actions[bot]: #### Subscribe to Label Action

cc @saulecabrera

<details>
This issue or pull request has been labeled: "wasmtime:api", "wasmtime:config", "winch"

Thus the following users have been cc'd because of the following labels:

* saulecabrera: winch

To subscribe or unsubscribe from this label, edit the <code>.github/subscribe-to-label.json</code> configuration file.

[Learn more.](https://github.com/bytecodealliance/subscribe-to-label-action)
</details>
github-actions[bot]: #### Label Messager: wasmtime:config

It looks like you are changing Wasmtime's configuration options. Make sure to
complete this check list:

* [ ] If you added a new `Config` method, you wrote extensive documentation for
      it.

  <details>

  Our documentation should be of the following form:

  ```text
  Short, simple summary sentence.

  More details. These details can be multiple paragraphs. There should be
  information about not just the method, but its parameters and results as
  well.

  Is this method fallible? If so, when can it return an error?

  Can this method panic? If so, when does it panic?

  # Example

  Optional example here.
  ```

  </details>

* [ ] If you added a new `Config` method, or modified an existing one, you
  ensured that this configuration is exercised by the fuzz targets.

  <details>

  For example, if you expose a new strategy for allocating the next instance
  slot inside the pooling allocator, you should ensure that at least one of our
  fuzz targets exercises that new strategy.

  Often, all that is required of you is to ensure that there is a knob for this
  configuration option in [`wasmtime_fuzzing::Config`][fuzzing-config] (or one
  of its nested `struct`s).

  Rarely, this may require authoring a new fuzz target to specifically test this
  configuration. See [our docs on fuzzing][fuzzing-docs] for more details.

  </details>

* [ ] If you are enabling a configuration option by default, make sure that it
  has been fuzzed for at least two weeks before turning it on by default.

[fuzzing-config]: https://github.com/bytecodealliance/wasmtime/blob/ca0e8d0a1d8cefc0496dba2f77a670571d8fdcab/crates/fuzzing/src/generators.rs#L182-L194
[fuzzing-docs]: https://docs.wasmtime.dev/contributing-fuzzing.html


--------------------------------------------------------------------------------

<details>

To modify this label's message, edit the <code>.github/label-messager/wasmtime-config.md</code> file.

To add new label messages or remove existing label messages, edit the
<code>.github/label-messager.json</code> configuration file.

[Learn more.](https://github.com/bytecodealliance/label-messager-action)

</details>
fitzgen: FWIW, I did a 100-iteration sightglass run of the default suite and got these results:

```
instantiation :: cycles :: benchmarks/spidermonkey/benchmark.wasm

  No difference in performance.

  [472878 584750.13 1435913] cache.so
  [471975 647599.28 1762837] main.so

execution :: cycles :: benchmarks/pulldown-cmark/benchmark.wasm

  No difference in performance.

  [7422842 8744920.24 28196476] cache.so
  [7439782 9281270.19 32569639] main.so

instantiation :: cycles :: benchmarks/bz2/benchmark.wasm

  No difference in performance.

  [165456 245749.33 728069] cache.so
  [161764 251740.72 723740] main.so

execution :: cycles :: benchmarks/bz2/benchmark.wasm

  No difference in performance.

  [95484875 104221743.11 196171026] cache.so
  [95454637 105918836.28 208434699] main.so

compilation :: cycles :: benchmarks/pulldown-cmark/benchmark.wasm

  No difference in performance.

  [358282063 404458268.52 568579026] cache.so
  [367557812 399158971.64 551388853] main.so

compilation :: cycles :: benchmarks/bz2/benchmark.wasm

  No difference in performance.

  [194765165 225581880.12 437412507] cache.so
  [197703277 222917937.46 281571195] main.so

execution :: cycles :: benchmarks/spidermonkey/benchmark.wasm

  No difference in performance.

  [895587497 923280792.22 972838288] cache.so
  [891917856 927697965.55 1121115203] main.so

compilation :: cycles :: benchmarks/spidermonkey/benchmark.wasm

  No difference in performance.

  [8325711261 8502522129.94 8732981273] cache.so
  [8370183347 8530524550.49 9984684780] main.so

instantiation :: cycles :: benchmarks/pulldown-cmark/benchmark.wasm

  No difference in performance.

  [214742 289658.22 800685] cache.so
  [204642 290351.57 854625] main.so
```

I think it is safe to say that this doesn't have an affect on our regular suite, and I expect that the JS speed ups you are reporting rely on weval'ing first.
cfallin: Updated, let me know what you think!
cfallin: (Oh, and forgot to mention: thanks for the thorough benchmarks on this! I've gotta dig into why Sightglass is so finicky for me...)
github-actions[bot]: #### Subscribe to Label Action

cc @fitzgen

<details>
This issue or pull request has been labeled: "fuzzing", "wasmtime:ref-types"

Thus the following users have been cc'd because of the following labels:

* fitzgen: fuzzing, wasmtime:ref-types

To subscribe or unsubscribe from this label, edit the <code>.github/subscribe-to-label.json</code> configuration file.

[Learn more.](https://github.com/bytecodealliance/subscribe-to-label-action)
</details>
fitzgen: Oh also, I mentioned this in the Cranelift meeting, but wanted to put it down here for posterity too: it seems like the limit on the number of caches isn't actually implemented.
cfallin: Yep, I missed that entirely in this iteration of the patch -- sorry about that! Just updated. I ended up wiring the limit through to a new config option (with docs, CLI flag, fuzzing) so that I could easily test it without a huge test input.
cfallin: The merge queue did its job (nice!) and found a conflict with #8515 which merged after this PR's CI ran. I pushed a fix which is fairly small but @fitzgen maybe PTAL at least commit just to be sure?
cfallin: (or @jameysharp feel free to review last commit too as it interacts with your change in #8515!)

Patch:
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/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/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/src/lib.rs b/crates/types/src/lib.rs
index 2ffe76d01..32cf70040 100644
--- a/crates/types/src/lib.rs
+++ b/crates/types/src/lib.rs
@@ -973,6 +973,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 +1200,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/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/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
+;; }