Noz Wu

f128 Libcall Mismatch on Wasm64

Some time ago, I ran into an interesting bug. In short, comparing f128 values on wasm64-unknown-unknown could produce a broken artifact, while the build itself would still succeed without any error by default.

Reproduction

[package]
name = "libcall-mismatch-on-wasm64"
version = "0.1.0"
edition = "2024"

[lib]
crate-type = ['cdylib', 'rlib']
#![feature(f128)]

#[unsafe(no_mangle)]
pub fn issue(a: f128, b: f128) -> bool {
    a > b
}

This issue has already been fixed on nightly, so we install an older toolchain and build for the wasm64-unknown-unknown target:

rustup toolchain install nightly-2026-04-16
rustup component add rust-src --toolchain nightly-2026-04-16
cargo +nightly-2026-04-16 build --target wasm64-unknown-unknown \
    -Zbuild-std=panic_abort,std --release

The build completes without any error, but the generated Wasm is already broken:

$ wasm-tools print target/wasm64-unknown-unknown/release/libcall_mismatch_on_wasm64.wasm 
(module $libcall_mismatch_on_wasm64.wasm
  (func $signature_mismatch:__gttf2 (;0;) (type 0) (param i64 i64 i64 i64) (result i32)
    unreachable
  )
  (func $issue (;1;) (type 0) (param i64 i64 i64 i64) (result i32)
    local.get 0
    local.get 1
    local.get 2
    local.get 3
    call $signature_mismatch:__gttf2
    i32.const 0
    i32.gt_s
  )
)

As shown above, our > operation has actually been lowered to a call to a function named signature_mismatch:__gttf2. But the implementation of that function has been replaced with unreachable, so it will immediately trap at runtime.

The Strange Symbol

To explain what happened, we first need to understand what signature_mismatch:__gttf2 is. __gttf2 means:

gt = greater than
tf = tetra floating (a 16-byte (128-bit) floating-point data type)

So this is the function used to compare 128-bit floating-point numbers, exactly as expected.

As for signature_mismatch, after looking through the lld source code, we can see that this is how lld handles symbols with the same name but different signatures across object files. It renames the symbol to signature_mismatch:<NAME> and replaces the implementation with unreachable.

lld emits a warning or error for this case, but Rust does not print linker warnings by default. If we add the following to Cargo.toml and rebuild:

[lints.rust]
linker_messages = "warn"

we can see the problem:

warning: linker stderr: rust-lld: function signature mismatch: __gttf2
>>> defined as (i64, i64, i64, i64) -> i32 in libcall_mismatch_on_wasm64.libcall_mismatch_on_wasm64.329d4cb22e829c3b-cgu.0.rcgu.o
>>> defined as (i64, i64, i64, i64) -> i64 in libcompiler_builtins-98dc221c6c1a2013.rlib

Now the issue is visible: the same symbol has two different signatures. The former returns i32, while the latter returns i64. Next, we need to find where __gttf2 comes from.

Libcall and compiler-rt

When we compare f128 values with >, LLVM turns the operation into a call to __gttf2. This is called lowering to a libcall. The implementation of __gttf2 is provided by compiler-rt. For Rust’s wasm64-unknown-unknown target, compiler-rt is compiler-builtins.

With a quick search, we can find how compiler-builtins defines the return type of __gttf2:

cfg_if! {
    if #[cfg(any(target_arch = "aarch64", target_arch = "arm64ec"))] {
        // Aarch64 uses `int` rather than a pointer-sized value.
        pub type CmpResult = i32;
    } else if #[cfg(target_arch = "avr")] {
        // AVR uses a single byte.
        pub type CmpResult = i8;
    } else {
        // The default is word-sized. In LLVM's compiler-rt, this is done by using `long long` on
        // LLP64 ABIs and `long` on everything else.
        pub type CmpResult = isize;
    }
}

It is not hard to see that on wasm64, CmpResult becomes i64. This type was not chosen arbitrarily by Rust, either. It strictly follows LLVM’s definition:

// GCC uses long (at least for x86_64) as the return type of the comparison
// functions. We need to ensure that the return value is sign-extended in the
// same way as GCC expects (since otherwise GCC-generated __builtin_isinf
// returns true for finite 128-bit floating-point numbers).
#if defined(__aarch64__) || defined(__arm64ec__)
// AArch64 GCC overrides libgcc_cmp_return to use int instead of long.
typedef int CMP_RESULT;
#elif __SIZEOF_POINTER__ == 8 && __SIZEOF_LONG__ == 4
// LLP64 ABIs use long long instead of long.
typedef long long CMP_RESULT;
#elif __AVR__
// AVR uses a single byte for the return value.
typedef char CMP_RESULT;
#else
// Otherwise the comparison functions return long.
typedef long CMP_RESULT;
#endif

At this point, the conclusion is clear. LLVM’s libcall expects the comparison of f128 values to return i32, but compiler-rt determines the return type based on the word size, which makes it i64 on wasm64. The mismatch between the two sides causes this bug.

But why does the widely used wasm64-unknown-emscripten target not have the same problem? It turns out that Emscripten already carries a patch:

#if defined(__aarch64__) || defined(__arm64ec__) || defined(__wasm__)
typedef int CMP_RESULT;

The Fix

There are two possible fixes. One is to change the libcall return type to the word size as well. The other is to make the return type of the wasm compiler-rt implementation fixed to i32. The conclusion is that fixing it to i32 is the better tradeoff, because this comparison result does not need such a large range.

Here are the related issues and pull requests:

https://github.com/rust-lang/compiler-builtins/issues/1199

https://github.com/rust-lang/compiler-builtins/pull/1203

https://github.com/llvm/llvm-project/issues/192416

https://github.com/llvm/llvm-project/pull/194093

What surprised me is that this bug should have existed from the beginning, yet it went unnoticed for so long. It seems that very few people use f128 on wasm64-unknown-unknown.

#wasm #llvm

Reply to this post by email ↪