[RFC] Adding LLVM-IR-style floating-point literals to MLIR

Summary

I’d like to add LLVM-IR-style human-readable floating-point literals to MLIR’s textual assembly format: +inf/-inf, +qnan/-qnan, +nan(0x…)/+snan(0x…), and C-style hexadecimal floats (0x1.8p3), on both the parsing and printing sides. As part of the same change, FloatAttr values are built directly in the target type’s semantics instead of being routed through double, which fixes a latent precision bug for types wider than double (f80, f128).

This mirrors the recent LLVM IR change (RFC / #121838) and is a format change: downstream tooling and tests that string-match the old output will need updating.

Motivation

Today MLIR has no readable way to spell floating-point special values. The only option is the raw bit-pattern hex form, both on input and output:

// Today: what does this mean?
%0 = arith.constant 0x7F800000 : f32
%1 = arith.constant 0xFFF0000000000000 : f64
%2 = arith.constant 0x7FC00000 : f32

You have to decode the bits by hand to see that these are +inf : f32, -inf : f64, and a quiet NaN. This is hard to read, hard to write, and error-prone (the bit width must exactly match the type).

Also, the parser converts every float literal to a double before building the FloatAttr:

Token::getFloatingPointValue()  ->  double  ->  FloatAttr::get(type, double)

For types wider than double, this rounding may have unexpected rounding effects.

Proposal

New literal forms (parsed and printed)

%0 = arith.constant +inf : f32
%1 = arith.constant -inf : f64
%2 = arith.constant +qnan : f32              // quiet NaN
%3 = arith.constant -qnan : f16
%4 = arith.constant +nan(0x1) : f32          // quiet NaN with payload
%5 = arith.constant +snan(0x1) : f64         // signaling NaN with payload
%6 = arith.constant 0x1.8p3 : f64            // C-style hex float (== 12.0)
%7 = arith.constant dense<[+inf, -inf]> : tensor<2xf32>

Rules:

  • The sign is mandatory for inf/NaN (+inf, not inf). This keeps bare inf/nan usable as ordinary identifiers/keywords and avoids ambiguity (the same rule LLVM IR uses).
  • C-style hex floats (0x1.8p3) are distinguished from the existing bit-pattern form by the presence of a fractional part and/or a p/P binary exponent. The plain 0x… form (no dot, no exponent) is still parsed as a raw bit pattern, so existing IR keeps working.
  • Values are built directly in the target type’s semantics via APFloat::convertFromString, so NaN payloads are preserved and wide types are no longer double-rounded.
  • A special value literal that is not representable in its target type is an error, e.g., +inf for f4E2M1FN.

Printing

The printer emits the same forms, mirroring LLVM: infinities as +inf/-inf, quiet NaN as +qnan/-qnan, and other NaNs as +nan(0x…) / +snan(0x…) for IEEE-encoded types; see NaN spellings below for other types. Finite values are unchanged (shortest round-trippable decimal, else the hex bit-pattern fallback).

Special values print in the new format by default. Pass --mlir-print-float-special-literals-as-hex to restore the old hex output for backwards compatibility.

NaN spellings follow the type’s NaN encoding

Not every float type uses the IEEE NaN model. The +qnan / +nan(p) / +snan(p) forms above apply to IEEE-encoded types, where the payload and the quiet/signaling bit are meaningful. For non-IEEE types, we propose:

Encoding Example Types Printed Also parsed Rejected
IEEE f16, bf16, f32, f64, f80, f128, f8E5M2, f8E4M3 +qnan, -qnan, +snan(p), -snan(p), +nan(p), -nan(p)
AllOnes f8E4M3FN, f8E8M0FNU +nan, -nan (+nan only for signless types) +qnan, -qnan snan, any payload, negative on signless
NegativeZero f8E4M3FNUZ, f8E5M2FNUZ, f8E4M3B11FNUZ -nan -qnan positive NaN, snan, any payload

Consistent with the “not representable is an error” rule, the parser rejects a NaN spelling the type cannot represent (a payload or snan on these fp8 types, or a positive NaN on a negative-zero type) rather than silently re-encoding it. This keeps each printed form the only lossless text for its value.

Impact / backwards compatibility

This changes the textual output, so it is not fully transparent:

  1. Printed output for inf/NaN changes from hex bit patterns to the new textual forms. FileCheck tests and any tooling that scrapes MLIR text for 0x7F800000-style infinities/NaNs will need to be updated. A --mlir-print-float-special-literals-as-hex flag will be added to the tools for the moment to enable using the legacy format on output.
  2. Wider-than-double decimal literals now parse to the correctly-rounded value. This is a (small) numerical behavior change for f80/f128 constants; the in-tree bytecode goldens for 0.1 : f80 / 0.1 : f128 were regenerated.
  3. Input compatibility is preserved: the existing 0x<bits> : <type> bit-pattern form still parses exactly as before.

Relationship to LLVM IR

The design intentionally follows LLVM IR’s textual float literals. A few deliberate differences remain, to fit MLIR:

  • Overflow/underflow are tolerated (yielding inf/0) rather than raising a parse error as LLVM IR does. This preserves long-standing MLIR behavior (e.g. 1.0e999 : f64+inf, used as a sentinel in existing IR/tests).
  • The non-round-trippable finite fallback prints 0x<bits>, keeping MLIR’s existing bit-pattern form, rather than LLVM IR’s newer f0x<bits> spelling.
  • 0x<hex> without a dot/exponent keeps its MLIR meaning (a raw bit pattern whose width matches the type), not LLVM IR’s legacy double form.

Alternatives considered

  • Route all decimals through convertFromString with LLVM-style strict overflow/underflow errors. Cleaner parity, but changes overflow/underflow from lenient to hard errors, breaking existing IR. Rejected in favor of preserving current behavior. Open to change.
  • Use APFloat for printing/parsing Use same format as APFloat. Rejected to match LLVM syntax.

Implementation

I have a PR for functionality that we can clean up or apply whatever improvement if we agree this is worth it. IR changes are visible already there and seem way more readable.

Round-trip was validated across all builtin float types, over every special-value spelling and a wide range of NaN payloads, checking both textual idempotence and bit-level preservation. The test script is available as a gist.

References

In general, this sounds good to me. One important thing to consider is backward compatibility, perhaps we can add a printer option/flag and carry it forward for a couple of releases. A lot of downstream tooling around MLIR relies on the textual IR format (we know it shouldn’t but still), and in particular on the generic IR format, for stability. This change will break it, especially for cases where different versions of MLIR need to talk to each other.

Could you explain how this syntax is meant to work for the non-IEEE low-precision types like f8E4M3FN, f8E8M0FNU, and the *FNUZ ones? They don’t really fit the inf/qnan/nan(x)/snan(x) model, since some have no sign bit, no inf, or just one fixed NaN with no quiet/signaling meaning. For example f8E8M0FNU prints its NaN as +qnan, but neither “quiet” nor the + applies here.

It would help to document what these types round-trip to, meaning what you write in versus what gets printed back, and whether they match, so the intended behavior is clear and covered by tests.

This change will break it, especially for cases where different versions of MLIR need to talk to each other.

Agreed, I didn’t think of that :person_facepalming:. For my own curiosity: I know LLVM does not guarantee backward compatibility in the textual IR. Do we? (Note I still agree with the proposal, it’s just curiosity on my side :slight_smile: ).

For now: The new literal format will be the default. On top of that I’ll add --mlir-print-float-literals-as-hex, to enable hex printing for the special values.

Good catch, that behaviour isn’t documented but it is implemented. If a literal can’t be represented exactly in the target type, the parser rejects it with an error. I’ll add this to the RFC and the docs.

We don’t offer any guarantees either, but this looks cheap to add and makes you a good citizen of the project :slight_smile:

Hmm I mean for example in the current implementation f8E8M0FNU prints its NaN as +qnan, but neither “quiet” nor the + applies here. Could you clarify related behaviors?

Also for the printing logic, it seems that all are treated as IEEE-style NaN encoding so it may look weird for some low-precision FP types like E4M3FN.

Good point, thanks. I wasn’t aware of all the nuances of NaN encoding in these non-IEEE types! I agree we should follow the type’s NaN semantics for correctness.

The current draft prints and parses every NaN with the IEEE model (sign + quiet/signaling + payload). That is wrong for other types, of course. We can however check the type’s fltNanEncoding:

  • AllOnes (e.g. f8E4M3FN, f8E8M0FNU): the only NaN is the all-ones pattern. makeNaN forces the NaN to quiet and overwrites any payload, so a payload or an s-prefix is meaningless. The original printer emitted +nan(0x3).
  • NegativeZero (e.g. f8E4M3FNUZ, f8E5M2FNUZ): there is a single, always negative NaN. makeNaN forces the sign bit to 1, so +qnan or +snan(0x1) silently became the negative NaN and dropped the payload. Round-tripping the original output showed +snan(0x1) : f8E4M3FNUZ printing back as -nan(0x0).

So the updated RFC makes the text form encoding-aware:

  • IEEE encoding: unchanged (+qnan, -qnan, ±snan(p), ±nan(p)).
  • AllOnes: +nan / -nan, no payload, no signaling.
  • NegativeZero: -nan (it is always negative), no payload, no signaling.

When parsing, we will reject the spellings a type cannot represent (a positive NaN on an FNUZ type, or a payload / snan on any of these types) with a clear diagnostic, instead of silently re-encoding them. nan / qnan stay accepted where they alias the single NaN.

For NegativeZero, I still require the explicit - sign (you write -nan, never a bare nan), even though its NaN is “negative”. This keeps the lexer rule uniform, a sign is always mandatory for inf/NaN, so a bare nan stays an ordinary identifier and does not collide with other literals. It also simplifies parsing: the lexer never has to special-case a signless NaN.

This diverges on purpose from LLVM IR, which prints the IEEE form for every type and never branches on fltNanEncoding. LLVM IR gets away with that because it only has IEEE-encoded float types.

Just a drive-by comment: You may be interested in the short discussion on this old PR. [mlir][Parser] Add `nan` and `inf` keywords by matthias-springer · Pull Request #116176 · llvm/llvm-project · GitHub

Thanks Matthias, good context. Turns out that thread’s points are covered here: the full spectrum is supported (+qnan, ±snan(p), ±nan(p)), behaviour is specified per NaN encoding (IEEE vs non-IEEE), and it’s documented in the LangRef. I see we went the opposite way on keywords also: mandatory sign so standalone nan/inf stay identifiers. With @PragmaTwice’s suggestions in, I think we’re good to go.