How to Fix: fuzz: failed to compile `f32.demote_f64

6 min read

Fixing the fuzz: failed to compile `f32.demote_f64` Issue

A minimal WebAssembly module that should compile cleanly can fail as soon as it hits `f32.demote_f64`. That usually points to a bug in the compiler pipeline, feature gating logic, opcode decoder, or backend lowering for floating-point conversion instructions rather than a problem in the WAT itself.

The failing test case is straightforward:

(module
  (type (func (param f64) (result f32)))
  (func (type 0) (param f64) (result f32)
    local.get 0
    f32.demote_f64
  )
  (export "test" (func 0))
)

If this crashes a compiler or produces a compile-time failure during fuzzing, the problem is typically that the engine mishandles the numeric conversion opcode for demoting a 64-bit float to a 32-bit float.

Understanding the Root Cause

`f32.demote_f64` is a standard WebAssembly floating-point instruction. It takes one f64 from the operand stack and returns an f32. Semantically, this is a narrowing conversion, which means the engine must correctly handle:

  • Normal finite values
  • Subnormal values
  • NaN payloads
  • Positive and negative infinity
  • Rounding behavior when precision is lost

When fuzzing exposes a compile failure here, the root cause usually falls into one of these categories:

  1. Opcode decoding bug
    The frontend parses the instruction stream incorrectly or maps the opcode to the wrong internal IR node.
  2. Type-checking inconsistency
    The validator may recognize the instruction, but later compiler stages incorrectly expect the input and output types to remain the same width.
  3. IR lowering defect
    The compiler’s intermediate representation supports float conversions, but the lowering pass does not correctly translate f64 to f32 demotion into backend-specific instructions.
  4. Backend codegen gap
    One architecture backend may support float truncation or conversion differently, leading to an unimplemented path or invalid machine instruction emission.
  5. NaN canonicalization assumptions
    Some runtimes enforce specific NaN handling policies. If the compiler assumes all float narrowing can be simplified without preserving expected semantics, compilation may fail or assertions may trigger.

In other words, the WAT is valid. The bug exists because the compiler pipeline does not robustly implement a legal WebAssembly numeric conversion.

Step-by-Step Solution

The fix should be approached systematically so you can isolate whether the failure happens in parsing, validation, IR construction, optimization, or machine code generation.

1. Reproduce the failure with the smallest possible module

Start with the reduced test case and compile it directly instead of relying only on the fuzz harness.

(module
  (func (export "test") (param f64) (result f32)
    local.get 0
    f32.demote_f64
  )
)

Then run it through your compiler’s standalone compile path. If your project has debug flags, enable IR dumps, validation traces, and backend logging.

2. Confirm the instruction is accepted by the validator

The validator should infer this stack transition:

before: [f64]
after:  [f32]

If the validator rejects it or rewrites it incorrectly, inspect the instruction typing table. A typical internal definition should resemble:

f32.demote_f64: (f64) -> (f32)

Check that the opcode metadata is not accidentally reused from another conversion such as:

f64.promote_f32
i32.trunc_f64_s
f32.convert_i64_s

3. Verify IR generation for float demotion

In the frontend or IR builder, make sure the opcode becomes a dedicated conversion node rather than a generic cast with incorrect type assumptions.

// Pseudocode
Value input = pop(F64);
Value output = buildFloatDemote(input); // result type F32
push(F32, output);

Common bugs here include:

  • Creating an f64 result node by mistake
  • Using a signed integer conversion path
  • Dropping the result type during stack reconstruction
  • Using an optimization helper that assumes no precision loss

4. Inspect lowering to target-specific instructions

Once the IR is correct, the backend must lower the conversion cleanly. On most platforms this maps to a standard floating-point narrowing operation, but the exact instruction varies by architecture.

Your backend lowering logic should conceptually do this:

// Pseudocode
case Opcode::F32DemoteF64:
    src = getInput(0);      // F64
    dst = allocateF32Reg();
    emitFloatNarrow(dst, src);
    return dst;

If compilation fails only on one target, the issue is likely backend-specific. Compare behavior across architectures and check for missing instruction selection patterns.

5. Disable or audit optimization passes around float conversions

Fuzzing often triggers failures after the module validates successfully, which points to an optimization pass. Temporarily disable constant folding, simplification, or float canonicalization passes and retry compilation.

Look for transformations like:

// Incorrect simplification example
f32.demote_f64(x)  -->  x

That is invalid because the result type changes and precision may be lost.

Another risky pattern is folding through NaN-sensitive operations without preserving WebAssembly semantics.

6. Add a regression test at the compiler level

Once fixed, lock it down with a regression test that covers both successful compilation and runtime behavior.

(module
  (func (export "test") (param f64) (result f32)
    local.get 0
    f32.demote_f64
  )
)

Add runtime assertions for representative inputs:

test(1.5)        == 1.5f
test(0.0)        == 0.0f
test(-0.0)       preserves sign when observable
test(infinity)   == infinity
test(-infinity)  == -infinity
test(NaN)        == NaN

7. Add fuzz-focused coverage for nearby conversion instructions

If one conversion opcode is broken, adjacent ones may also be fragile. Expand coverage for:

f64.promote_f32
f32.convert_i32_s
f32.convert_i32_u
f32.convert_i64_s
f32.convert_i64_u
f64.convert_i32_s
f64.convert_i64_u

This prevents future regressions in the same numeric lowering subsystem.

8. Example of a practical fix checklist

1. Parse module
2. Validate stack signature for f32.demote_f64
3. Build IR node with input F64 and output F32
4. Ensure optimizers do not erase narrowing conversion
5. Lower to target backend float-narrow instruction
6. Add compile test
7. Add runtime semantic test
8. Re-run fuzz corpus

Common Edge Cases

Even after fixing the core issue, several edge cases can still break compilation or correctness.

NaN handling

NaN values are a classic source of bugs. Some engines preserve payloads differently, while others canonicalize NaNs in specific passes. Make sure your implementation does not crash, assert, or miscompile when demoting NaN values.

Subnormal values

Very small f64 numbers may become subnormal or zero when converted to f32. Backends that flush denormals incorrectly can produce inconsistent behavior.

Negative zero

-0.0 must not accidentally become +0.0 in optimization passes that assume all zeros are interchangeable.

Target-specific instruction support

Some backends may have partial floating-point support or different register constraints. If the bug reproduces only on one platform, inspect backend legalization and register allocation.

Incorrect constant folding

If the compiler folds `f32.demote_f64` at compile time, verify that the folded result matches WebAssembly semantics exactly, especially for infinities and NaNs.

Feature flag confusion

This instruction is part of core numeric WebAssembly behavior, so if your compiler hides it behind an optional feature gate, the gate may be wrong. Review parser and validator feature tables to ensure baseline instructions are always enabled.

FAQ

Why is `f32.demote_f64` valid WebAssembly if my compiler rejects it?

Because the instruction itself is standard and legal. A rejection or compile failure usually means your toolchain has a bug in validation, IR generation, or backend lowering, not that the WAT is invalid.

Is this a parser bug or a code generation bug?

It can be either, but fuzzing-related compile failures commonly come from later stages after parsing succeeds. The fastest way to tell is to inspect each stage separately: parser, validator, IR dump, optimization passes, and backend emission.

How do I prevent similar bugs for other float conversion instructions?

Group all numeric conversion opcodes under shared validation and testing infrastructure. Add regression tests for every conversion direction, include NaN and infinity cases, and run fuzzing against both optimized and unoptimized compiler pipelines.

For long-term stability, pair the fix with reduced regression tests, architecture-specific backend tests, and continuous fuzzing. That combination catches both semantic bugs and backend-only compile failures before they reach users.

Leave a Reply

Your email address will not be published. Required fields are marked *