Stream: git-wasmtime

Topic: wasmtime / issue #14142 Cranelift `shrink_wrap` cold blocks


view this post on Zulip Wasmtime GitHub notifications bot (Aug 15 2026 at 12:21):

coastalwhite opened issue #14142:

Thanks for filing a feature request! Please fill out the TODOs below.

Feature

Currently, the Cranelift backend only performs caller/callee saved register clobbering in the prologue and epilogue of functions. If a function contains a cold block (or possibly just a single conditional block) that causes the clobbering, I would like for this clobbering to happen in that block.

Benefit

This would allow for guards to be implemented efficiently in functions that don't call other functions. This applies to, for example, panic!(), Vec::push and dynamic language type guards and would massively reduce the number of instructions needed to express such functions.

In my use case, where I use the Tail calling convention for discrete event simulation, working around this limitation (where it was possible) sped up my code by more than 2x. Before, the functions were simply dominated by these clobbering instructions.

Example code snippet:

enum Value {
    Number(i32),
    String(u64),
}

#[unsafe(no_mangle)]
pub fn add(a: Value, b: Value) -> Value {
    let (Value::Number(a), Value::Number(b)) = (&a, &b) else {
        return add_cold(a, b);
    };

    Value::Number(a + b)
}

#[cold]
#[inline(never)]
#[repr(C)]
pub fn add_cold(a: Value, b: Value) -> Value {
    todo!()
}

Here the add function does not generally need to clobber any callee-saved registers. There should be a guard that a, b are numbers, and only when that is not true should it start clobbering those registers.

Implementation

I think the trouble here comes from the unwind_info. So I suppose my initial proposal is to implement this for unwind_info = false and only for blocks marked with cold. I think that should be relatively doable.

Alternatives

I think other codegen backends have more detailed analysis, but I think that is both too expensive and more error-prone.

view this post on Zulip Wasmtime GitHub notifications bot (Aug 15 2026 at 12:21):

coastalwhite edited issue #14142:

Feature

Currently, the Cranelift backend only performs caller/callee saved register clobbering in the prologue and epilogue of functions. If a function contains a cold block (or possibly just a single conditional block) that causes the clobbering, I would like for this clobbering to happen in that block.

Benefit

This would allow for guards to be implemented efficiently in functions that don't call other functions. This applies to, for example, panic!(), Vec::push and dynamic language type guards and would massively reduce the number of instructions needed to express such functions.

In my use case, where I use the Tail calling convention for discrete event simulation, working around this limitation (where it was possible) sped up my code by more than 2x. Before, the functions were simply dominated by these clobbering instructions.

Example code snippet:

enum Value {
    Number(i32),
    String(u64),
}

#[unsafe(no_mangle)]
pub fn add(a: Value, b: Value) -> Value {
    let (Value::Number(a), Value::Number(b)) = (&a, &b) else {
        return add_cold(a, b);
    };

    Value::Number(a + b)
}

#[cold]
#[inline(never)]
#[repr(C)]
pub fn add_cold(a: Value, b: Value) -> Value {
    todo!()
}

Here the add function does not generally need to clobber any callee-saved registers. There should be a guard that a, b are numbers, and only when that is not true should it start clobbering those registers.

Implementation

I think the trouble here comes from the unwind_info. So I suppose my initial proposal is to implement this for unwind_info = false and only for blocks marked with cold. I think that should be relatively doable.

Alternatives

I think other codegen backends have more detailed analysis, but I think that is both too expensive and more error-prone.

view this post on Zulip Wasmtime GitHub notifications bot (Aug 19 2026 at 19:56):

cfallin closed issue #14142:

Feature

Currently, the Cranelift backend only performs caller/callee saved register clobbering in the prologue and epilogue of functions. If a function contains a cold block (or possibly just a single conditional block) that causes the clobbering, I would like for this clobbering to happen in that block.

Benefit

This would allow for guards to be implemented efficiently in functions that don't call other functions. This applies to, for example, panic!(), Vec::push and dynamic language type guards and would massively reduce the number of instructions needed to express such functions.

In my use case, where I use the Tail calling convention for discrete event simulation, working around this limitation (where it was possible) sped up my code by more than 2x. Before, the functions were simply dominated by these clobbering instructions.

Example code snippet:

enum Value {
    Number(i32),
    String(u64),
}

#[unsafe(no_mangle)]
pub fn add(a: Value, b: Value) -> Value {
    let (Value::Number(a), Value::Number(b)) = (&a, &b) else {
        return add_cold(a, b);
    };

    Value::Number(a + b)
}

#[cold]
#[inline(never)]
#[repr(C)]
pub fn add_cold(a: Value, b: Value) -> Value {
    todo!()
}

Here the add function does not generally need to clobber any callee-saved registers. There should be a guard that a, b are numbers, and only when that is not true should it start clobbering those registers.

Implementation

I think the trouble here comes from the unwind_info. So I suppose my initial proposal is to implement this for unwind_info = false and only for blocks marked with cold. I think that should be relatively doable.

Alternatives

I think other codegen backends have more detailed analysis, but I think that is both too expensive and more error-prone.

view this post on Zulip Wasmtime GitHub notifications bot (Aug 19 2026 at 19:56):

cfallin commented on issue #14142:

Actually moving where prologues and epilogues are generated would be such a substantial change to the backends, with cross-cutting changes to the abstractions between lowering, ABI code, and regalloc, that it seems unlikely to be worth it, IMHO. Everything is carefully orchestrated with the assumption that we compute clobbers across the whole function body, have one stack frame layout that is consistent for the whole body, and inject prologues/epilogues at the edges. You would need more conditions than disallowing unwind_info -- for example, you would need to see that regalloc did not happen to need more than N registers (forcing use of clobbers) and did not happen to spill anything. Overall, the amount of careful design time this would require would place it low on our priority list.

However, you could emulate this behavior in a best-effort way with tail calls to out-of-lined cold paths, I believe. For example, if I compile this CLIF

function %f(i32, i64, i64) -> i64 tail {
    fn0 = colocated %g(i64, i64) -> i64 tail

block0(v0: i32, v1: i64, v2: i64):
    brif v0, block1, block2

block1:
    return_call fn0(v1, v2)

block2:
    v3 = iadd v1, v2
    return v3
}

function %g(i64, i64) -> i64 tail {
    ss0 = explicit_slot 8
block0(v0: i64, v1: i64):
    v2 = stack_addr.i64 ss0
    store v0, v2
    v3 = load.i64 v2
    return v3
}

(using the stackslot in the cold path %g to force creation of a stackframe; clobbers or spills would do similarly), and compile with cargo run -p cranelift-tools -- compile --target aarch64 -D test.clif, I get

Disassembly of 24 bytes <%f>:
   0:   62 00 00 35             cbnz    w2, #0xc
   4:   62 00 04 8b             add x2, x3, x4
   8:   c0 03 5f d6             ret
   c:   e2 03 03 aa             mov x2, x3
  10:   e3 03 04 aa             mov x3, x4
  14:   00 00 00 14             b   #0x14

Disassembly of 32 bytes <%g>:
   0:   fd 7b bf a9             stp x29, x30, [sp, #-0x10]!
   4:   fd 03 00 91             mov x29, sp
   8:   ff 43 00 d1             sub sp, sp, #0x10
   c:   e2 03 00 f8             stur    x2, [sp]
  10:   e2 03 40 f8             ldur    x2, [sp]
  14:   ff 43 00 91             add sp, sp, #0x10
  18:   fd 7b c1 a8             ldp x29, x30, [sp], #0x10
  1c:   c0 03 5f d6             ret

where we see the fastpath is compare/branch, add, ret, with no stack setup. Hopefully that works for your needs -- I'll go ahead and close the issue as the literal request is unlikely to happen but let us know if the above doesn't work for you.

view this post on Zulip Wasmtime GitHub notifications bot (Aug 19 2026 at 19:57):

cfallin edited a comment on issue #14142:

Actually moving where prologues and epilogues are generated would be such a substantial change to the backends, with cross-cutting changes to the abstractions between lowering, ABI code, and regalloc, that it seems unlikely to be worth it, IMHO. Everything is carefully orchestrated with the assumption that we compute clobbers across the whole function body, have one stack frame layout that is consistent for the whole body, and inject prologues/epilogues at the edges. You would need more conditions than disallowing unwind_info -- for example, you would need to see that regalloc did not happen to need more than N registers (forcing use of clobbers) and did not happen to spill anything. Overall, the amount of careful design time this would require would place it low on our priority list.

However, you could emulate this behavior in a best-effort way with tail calls to out-of-lined cold paths, I believe. For example, if I write this CLIF

function %f(i32, i64, i64) -> i64 tail {
    fn0 = colocated %g(i64, i64) -> i64 tail

block0(v0: i32, v1: i64, v2: i64):
    brif v0, block1, block2

block1:
    return_call fn0(v1, v2)

block2:
    v3 = iadd v1, v2
    return v3
}

function %g(i64, i64) -> i64 tail {
    ss0 = explicit_slot 8
block0(v0: i64, v1: i64):
    v2 = stack_addr.i64 ss0
    store v0, v2
    v3 = load.i64 v2
    return v3
}

(using the stackslot in the cold path %g to force creation of a stackframe; clobbers or spills would do similarly), and compile with cargo run -p cranelift-tools -- compile --target aarch64 -D test.clif, I get

Disassembly of 24 bytes <%f>:
   0:   62 00 00 35             cbnz    w2, #0xc
   4:   62 00 04 8b             add x2, x3, x4
   8:   c0 03 5f d6             ret
   c:   e2 03 03 aa             mov x2, x3
  10:   e3 03 04 aa             mov x3, x4
  14:   00 00 00 14             b   #0x14

Disassembly of 32 bytes <%g>:
   0:   fd 7b bf a9             stp x29, x30, [sp, #-0x10]!
   4:   fd 03 00 91             mov x29, sp
   8:   ff 43 00 d1             sub sp, sp, #0x10
   c:   e2 03 00 f8             stur    x2, [sp]
  10:   e2 03 40 f8             ldur    x2, [sp]
  14:   ff 43 00 91             add sp, sp, #0x10
  18:   fd 7b c1 a8             ldp x29, x30, [sp], #0x10
  1c:   c0 03 5f d6             ret

where we see the fastpath is compare/branch, add, ret, with no stack setup. Hopefully that works for your needs -- I'll go ahead and close the issue as the literal request is unlikely to happen but let us know if the above doesn't work for you.

view this post on Zulip Wasmtime GitHub notifications bot (Aug 20 2026 at 20:08):

coastalwhite commented on issue #14142:

I understand if it is architecturally too big of a change.

The problem with using tailcalls is that you can't continue your function afterward. I think things like bound checks or other conditionals cannot get away with that. How would you for write a Vec::push that has a coldpath of Vec::reserve in the middle of another function?

view this post on Zulip Wasmtime GitHub notifications bot (Aug 20 2026 at 20:25):

cfallin commented on issue #14142:

You can definitely continue execution of a shared tail, by factoring that shared tail into (yet another) function and tail-calling it.

Or, if it's an out-lined cold path like Vec::reserve, simply call it. The outer function will now need a stackframe (because any non-leaf function does), but will not spill anything more than required for the outer/fastpath logic.

The issue with a desire to enter and exit a region where a stack frame exists is that one has to reason about a shifting environment where different values are available at different times. It simply is not how the compiler is architected, sorry: we assume that if there is a stackframe, it is always available and we can always spill and reload. I think you'll find that most compilers with a "real" regalloc are similar.

view this post on Zulip Wasmtime GitHub notifications bot (Aug 20 2026 at 20:31):

coastalwhite commented on issue #14142:

That is a great hint, thank you. I will see if I can get that to work. Thank you very much for the help and for the maintenance of this crate.

It is great to see that with some finagling, I managed to get it to both compile an order of magnitude faster than the old C -> Clang backend and actually also run faster. Great job and thank you again.


Last updated: Aug 30 2026 at 09:07 UTC